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.
- package/README.md +98 -55
- 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 +39 -3
- package/lib/billingManager.js +235 -123
- package/lib/calculator.js +19 -138
- 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 +397 -174
- 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 +178 -0
- package/lib/utils/typeMapper.js +19 -0
- package/main.js +71 -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);
|
|
@@ -356,7 +325,9 @@ class MultiMeterManager {
|
|
|
356
325
|
}
|
|
357
326
|
|
|
358
327
|
const basePath = `${type}.${meterName}`;
|
|
359
|
-
this.adapter.log.debug(`
|
|
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
|
-
|
|
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
|
-
//
|
|
377
|
-
if (
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
//
|
|
352
|
+
// 2. Update meter reading states
|
|
353
|
+
await this.adapter.setStateAsync(`${basePath}.info.meterReading`, consumption, true);
|
|
383
354
|
if (type === 'gas') {
|
|
384
|
-
|
|
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
|
-
//
|
|
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 (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
//
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
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
|
|
425
|
-
calculator.roundToDecimals((
|
|
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
|
|
430
|
-
calculator.roundToDecimals((
|
|
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.
|
|
435
|
-
calculator.roundToDecimals((
|
|
593
|
+
`${basePath}.consumption.weeklyVolume${suffix}`,
|
|
594
|
+
calculator.roundToDecimals((state?.val || 0) + deltaVolume, 2),
|
|
436
595
|
true,
|
|
437
596
|
);
|
|
438
597
|
}
|
|
598
|
+
}
|
|
439
599
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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.
|
|
480
|
-
calculator.roundToDecimals(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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 +
|
|
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
|
|
604
|
-
const balance = totalYearlyCost - paidTotal;
|
|
784
|
+
const balanceRes = billingHelper.calculateBalance(config.abschlag, monthsSinceYearStart, totalYearlyCost);
|
|
605
785
|
|
|
606
786
|
this.adapter.log.debug(
|
|
607
|
-
`[${basePath}]
|
|
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`,
|
|
611
|
-
await this.adapter.setStateAsync(`${basePath}.costs.balance`,
|
|
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),
|