iobroker.utility-monitor 1.4.2
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/LICENSE +21 -0
- package/README.md +384 -0
- package/admin/i18n/de.json +5 -0
- package/admin/i18n/en.json +5 -0
- package/admin/i18n/es.json +5 -0
- package/admin/i18n/fr.json +5 -0
- package/admin/i18n/it.json +5 -0
- package/admin/i18n/nl.json +5 -0
- package/admin/i18n/pl.json +5 -0
- package/admin/i18n/pt.json +5 -0
- package/admin/i18n/ru.json +5 -0
- package/admin/i18n/uk.json +5 -0
- package/admin/i18n/zh-cn.json +5 -0
- package/admin/jsonConfig.json +1542 -0
- package/admin/utility-monitor.png +0 -0
- package/io-package.json +188 -0
- package/lib/adapter-config.d.ts +19 -0
- package/lib/billingManager.js +806 -0
- package/lib/calculator.js +254 -0
- package/lib/configParser.js +92 -0
- package/lib/consumptionManager.js +407 -0
- package/lib/messagingHandler.js +339 -0
- package/lib/multiMeterManager.js +749 -0
- package/lib/stateManager.js +1556 -0
- package/main.js +297 -0
- package/package.json +80 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const calculator = require('./calculator');
|
|
4
|
+
const stateManager = require('./stateManager');
|
|
5
|
+
const { parseConfigNumber } = require('./configParser');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* MultiMeterManager handles multiple meters per utility type
|
|
9
|
+
* Supports unlimited custom-named meters (e.g., "Erdgeschoss", "Werkstatt")
|
|
10
|
+
*/
|
|
11
|
+
class MultiMeterManager {
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} adapter - ioBroker adapter instance
|
|
14
|
+
* @param {object} consumptionManager - ConsumptionManager instance
|
|
15
|
+
* @param {object} billingManager - BillingManager instance
|
|
16
|
+
*/
|
|
17
|
+
constructor(adapter, consumptionManager, billingManager) {
|
|
18
|
+
this.adapter = adapter;
|
|
19
|
+
this.consumptionManager = consumptionManager;
|
|
20
|
+
this.billingManager = billingManager;
|
|
21
|
+
this.lastSensorValues = {};
|
|
22
|
+
this.meterRegistry = {}; // Maps sensorDP → {type, meterName}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maps internal utility type to config/state name
|
|
27
|
+
*
|
|
28
|
+
* @param {string} type - gas, water, or electricity
|
|
29
|
+
* @returns {string} - gas, wasser, or strom
|
|
30
|
+
*/
|
|
31
|
+
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
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Gets all configured meters for a utility type
|
|
61
|
+
* Returns array with main meter + additional meters
|
|
62
|
+
*
|
|
63
|
+
* @param {string} type - Utility type
|
|
64
|
+
* @returns {Array} - Array of {name, config} objects
|
|
65
|
+
*/
|
|
66
|
+
getMetersForType(type) {
|
|
67
|
+
const configType = this.getConfigType(type);
|
|
68
|
+
const meters = [];
|
|
69
|
+
|
|
70
|
+
// Main meter (always present if type is active)
|
|
71
|
+
const mainActive = this.adapter.config[`${configType}Aktiv`];
|
|
72
|
+
if (mainActive) {
|
|
73
|
+
meters.push({
|
|
74
|
+
name: 'main',
|
|
75
|
+
config: {
|
|
76
|
+
sensorDP: this.adapter.config[`${configType}SensorDP`],
|
|
77
|
+
preis: parseConfigNumber(this.adapter.config[`${configType}Preis`], 0),
|
|
78
|
+
offset: parseConfigNumber(this.adapter.config[`${configType}Offset`], 0),
|
|
79
|
+
initialReading: parseConfigNumber(this.adapter.config[`${configType}InitialReading`], 0),
|
|
80
|
+
contractStart: this.adapter.config[`${configType}ContractStart`],
|
|
81
|
+
grundgebuehr: parseConfigNumber(this.adapter.config[`${configType}Grundgebuehr`], 0),
|
|
82
|
+
jahresgebuehr: parseConfigNumber(this.adapter.config[`${configType}Jahresgebuehr`], 0),
|
|
83
|
+
abschlag: parseConfigNumber(this.adapter.config[`${configType}Abschlag`], 0),
|
|
84
|
+
htNtEnabled: this.adapter.config[`${configType}HtNtEnabled`] || false,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Additional meters from array config
|
|
90
|
+
const additionalMeters = this.adapter.config[`${configType}AdditionalMeters`];
|
|
91
|
+
if (Array.isArray(additionalMeters)) {
|
|
92
|
+
for (const meterConfig of additionalMeters) {
|
|
93
|
+
if (meterConfig && meterConfig.name && meterConfig.sensorDP) {
|
|
94
|
+
const normalizedName = this.normalizeMeterName(meterConfig.name);
|
|
95
|
+
|
|
96
|
+
// Debug: Log raw config for troubleshooting
|
|
97
|
+
this.adapter.log.debug(
|
|
98
|
+
`[${type}] Meter "${normalizedName}": preis=${meterConfig.preis} (${typeof meterConfig.preis}), grundgebuehr=${meterConfig.grundgebuehr} (${typeof meterConfig.grundgebuehr}), abschlag=${meterConfig.abschlag} (${typeof meterConfig.abschlag})`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const parsedConfig = {
|
|
102
|
+
sensorDP: meterConfig.sensorDP,
|
|
103
|
+
preis: parseConfigNumber(meterConfig.preis, 0),
|
|
104
|
+
offset: parseConfigNumber(meterConfig.offset, 0),
|
|
105
|
+
initialReading: parseConfigNumber(meterConfig.initialReading, 0),
|
|
106
|
+
contractStart: meterConfig.contractStart,
|
|
107
|
+
grundgebuehr: parseConfigNumber(meterConfig.grundgebuehr, 0),
|
|
108
|
+
jahresgebuehr: parseConfigNumber(meterConfig.jahresgebuehr, 0),
|
|
109
|
+
abschlag: parseConfigNumber(meterConfig.abschlag, 0),
|
|
110
|
+
htNtEnabled: false, // Additional meters don't support HT/NT
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
meters.push({
|
|
114
|
+
name: normalizedName,
|
|
115
|
+
displayName: meterConfig.name,
|
|
116
|
+
config: parsedConfig,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return meters;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Finds meter by sensor datapoint
|
|
127
|
+
*
|
|
128
|
+
* @param {string} sensorDP - Sensor datapoint ID
|
|
129
|
+
* @returns {object|null} - {type, meterName} or null
|
|
130
|
+
*/
|
|
131
|
+
findMeterBySensor(sensorDP) {
|
|
132
|
+
return this.meterRegistry[sensorDP] || null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Initializes all meters for a utility type
|
|
137
|
+
*
|
|
138
|
+
* @param {string} type - Utility type
|
|
139
|
+
* @returns {Promise<void>}
|
|
140
|
+
*/
|
|
141
|
+
async initializeType(type) {
|
|
142
|
+
const meters = this.getMetersForType(type);
|
|
143
|
+
|
|
144
|
+
if (meters.length === 0) {
|
|
145
|
+
this.adapter.log.debug(`No meters configured for ${type}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.adapter.log.info(`Initializing ${meters.length} meter(s) for ${type}`);
|
|
150
|
+
|
|
151
|
+
// Initialize each meter
|
|
152
|
+
for (const meter of meters) {
|
|
153
|
+
await this.initializeMeter(type, meter.name, meter.config, meter.displayName);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Create totals structure if multiple meters exist
|
|
157
|
+
if (meters.length > 1) {
|
|
158
|
+
await stateManager.createTotalsStructure(this.adapter, type);
|
|
159
|
+
await this.updateTotalCosts(type);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Cleanup removed meters
|
|
163
|
+
await this.cleanupRemovedMeters(type, meters);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Initializes a specific meter
|
|
168
|
+
*
|
|
169
|
+
* @param {string} type - Utility type
|
|
170
|
+
* @param {string} meterName - Meter name ('main' or normalized custom name)
|
|
171
|
+
* @param {object} config - Meter configuration
|
|
172
|
+
* @param {string} displayName - Original display name (optional)
|
|
173
|
+
* @returns {Promise<void>}
|
|
174
|
+
*/
|
|
175
|
+
async initializeMeter(type, meterName, config, displayName) {
|
|
176
|
+
const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
|
|
177
|
+
const label = displayName || meterName;
|
|
178
|
+
|
|
179
|
+
this.adapter.log.info(`Initializing ${type} meter: ${label}`);
|
|
180
|
+
|
|
181
|
+
if (!config.sensorDP) {
|
|
182
|
+
this.adapter.log.warn(`${type} meter "${label}" has no sensor datapoint configured!`);
|
|
183
|
+
await this.adapter.setStateAsync(`${basePath}.info.sensorActive`, false, true);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Create state structure
|
|
188
|
+
await stateManager.createMeterStructure(this.adapter, type, meterName, config);
|
|
189
|
+
|
|
190
|
+
// Register sensor in registry
|
|
191
|
+
this.meterRegistry[config.sensorDP] = { type, meterName };
|
|
192
|
+
|
|
193
|
+
this.adapter.log.debug(`Using sensor datapoint for ${type}.${meterName}: ${config.sensorDP}`);
|
|
194
|
+
|
|
195
|
+
// Log configured contract start
|
|
196
|
+
if (config.contractStart) {
|
|
197
|
+
this.adapter.log.info(`${type}.${meterName}: Contract start: ${config.contractStart}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Subscribe to sensor datapoint
|
|
201
|
+
this.adapter.subscribeForeignStates(config.sensorDP);
|
|
202
|
+
await this.adapter.setStateAsync(`${basePath}.info.sensorActive`, true, true);
|
|
203
|
+
this.adapter.log.debug(`Subscribed to ${type}.${meterName} sensor: ${config.sensorDP}`);
|
|
204
|
+
|
|
205
|
+
// Restore last sensor value from persistent state
|
|
206
|
+
const lastReading = await this.adapter.getStateAsync(`${basePath}.info.meterReading`);
|
|
207
|
+
if (lastReading && typeof lastReading.val === 'number') {
|
|
208
|
+
this.lastSensorValues[config.sensorDP] = lastReading.val;
|
|
209
|
+
this.adapter.log.debug(`${type}.${meterName}: Restored last sensor value: ${lastReading.val}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Initialize with current sensor value
|
|
213
|
+
try {
|
|
214
|
+
const sensorState = await this.adapter.getForeignStateAsync(config.sensorDP);
|
|
215
|
+
if (sensorState && sensorState.val !== null && typeof sensorState.val === 'number') {
|
|
216
|
+
await this.handleSensorUpdate(type, meterName, config.sensorDP, sensorState.val);
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
this.adapter.log.warn(`Could not read initial value from ${config.sensorDP}: ${error.message}`);
|
|
220
|
+
await this.adapter.setStateAsync(`${basePath}.info.sensorActive`, false, true);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Initialize period start timestamps
|
|
224
|
+
const nowIso = calculator.formatDateString(new Date());
|
|
225
|
+
const timestampRoles = ['lastDayStart', 'lastMonthStart', 'lastYearStart'];
|
|
226
|
+
|
|
227
|
+
for (const role of timestampRoles) {
|
|
228
|
+
const statePath = `${basePath}.statistics.${role}`;
|
|
229
|
+
const state = await this.adapter.getStateAsync(statePath);
|
|
230
|
+
|
|
231
|
+
if (!state || !state.val || typeof state.val === 'number') {
|
|
232
|
+
if (role === 'lastYearStart' && (!state || !state.val)) {
|
|
233
|
+
const contractStart = calculator.parseGermanDate(config.contractStart);
|
|
234
|
+
let yearStartDate;
|
|
235
|
+
|
|
236
|
+
if (contractStart && !isNaN(contractStart.getTime())) {
|
|
237
|
+
const now = new Date();
|
|
238
|
+
yearStartDate = new Date(
|
|
239
|
+
now.getFullYear(),
|
|
240
|
+
contractStart.getMonth(),
|
|
241
|
+
contractStart.getDate(),
|
|
242
|
+
12,
|
|
243
|
+
0,
|
|
244
|
+
0,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if (yearStartDate > now) {
|
|
248
|
+
yearStartDate.setFullYear(now.getFullYear() - 1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!yearStartDate) {
|
|
253
|
+
yearStartDate = new Date(new Date().getFullYear(), 0, 1, 12, 0, 0);
|
|
254
|
+
}
|
|
255
|
+
await this.adapter.setStateAsync(statePath, calculator.formatDateString(yearStartDate), true);
|
|
256
|
+
} else if (typeof state?.val === 'number') {
|
|
257
|
+
await this.adapter.setStateAsync(statePath, calculator.formatDateString(new Date(state.val)), true);
|
|
258
|
+
} else {
|
|
259
|
+
await this.adapter.setStateAsync(statePath, nowIso, true);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Initialize yearly consumption from initial reading if set
|
|
265
|
+
if (config.initialReading > 0) {
|
|
266
|
+
const sensorState = await this.adapter.getForeignStateAsync(config.sensorDP);
|
|
267
|
+
if (sensorState && typeof sensorState.val === 'number') {
|
|
268
|
+
let currentRaw = sensorState.val;
|
|
269
|
+
|
|
270
|
+
if (config.offset !== 0) {
|
|
271
|
+
currentRaw = currentRaw - config.offset;
|
|
272
|
+
this.adapter.log.debug(
|
|
273
|
+
`Applied offset for ${type}.${meterName}: -${config.offset}, new value: ${currentRaw}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
let yearlyConsumption = Math.max(0, currentRaw - config.initialReading);
|
|
277
|
+
|
|
278
|
+
// For gas: convert m³ to kWh
|
|
279
|
+
if (type === 'gas') {
|
|
280
|
+
const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
|
|
281
|
+
const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
|
|
282
|
+
const yearlyVolume = yearlyConsumption;
|
|
283
|
+
yearlyConsumption = calculator.convertGasM3ToKWh(yearlyConsumption, brennwert, zZahl);
|
|
284
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.yearlyVolume`, yearlyVolume, true);
|
|
285
|
+
this.adapter.log.info(
|
|
286
|
+
`Init yearly ${type}.${meterName}: ${yearlyConsumption.toFixed(2)} kWh = ${(currentRaw - config.initialReading).toFixed(2)} m³`,
|
|
287
|
+
);
|
|
288
|
+
} else {
|
|
289
|
+
this.adapter.log.info(`Init yearly ${type}.${meterName}: ${yearlyConsumption.toFixed(2)}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyConsumption, true);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Update current price
|
|
297
|
+
await this.updateCurrentPrice(type, meterName, config);
|
|
298
|
+
|
|
299
|
+
// Update billing countdown
|
|
300
|
+
if (config.contractStart) {
|
|
301
|
+
const startDate = calculator.parseGermanDate(config.contractStart);
|
|
302
|
+
if (startDate) {
|
|
303
|
+
const today = new Date();
|
|
304
|
+
const nextAnniversary = new Date(startDate);
|
|
305
|
+
nextAnniversary.setFullYear(today.getFullYear());
|
|
306
|
+
|
|
307
|
+
if (nextAnniversary < today) {
|
|
308
|
+
nextAnniversary.setFullYear(today.getFullYear() + 1);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const msPerDay = 1000 * 60 * 60 * 24;
|
|
312
|
+
const daysRemaining = Math.ceil((nextAnniversary.getTime() - today.getTime()) / msPerDay);
|
|
313
|
+
const displayPeriodEnd = new Date(nextAnniversary);
|
|
314
|
+
displayPeriodEnd.setDate(displayPeriodEnd.getDate() - 1);
|
|
315
|
+
|
|
316
|
+
this.adapter.log.debug(
|
|
317
|
+
`[${basePath}] Billing countdown: contractStart=${config.contractStart}, daysRemaining=${daysRemaining}, periodEnd=${displayPeriodEnd.toLocaleDateString('de-DE')}`,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
await this.adapter.setStateAsync(`${basePath}.billing.daysRemaining`, daysRemaining, true);
|
|
321
|
+
await this.adapter.setStateAsync(
|
|
322
|
+
`${basePath}.billing.periodEnd`,
|
|
323
|
+
displayPeriodEnd.toLocaleDateString('de-DE'),
|
|
324
|
+
true,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Initial cost calculation
|
|
330
|
+
await this.updateCosts(type, meterName, config);
|
|
331
|
+
|
|
332
|
+
this.adapter.log.debug(`Meter initialization completed for ${type}.${meterName}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Handles sensor value updates
|
|
337
|
+
*
|
|
338
|
+
* @param {string} type - Utility type
|
|
339
|
+
* @param {string} meterName - Meter name
|
|
340
|
+
* @param {string} sensorDP - Sensor datapoint ID
|
|
341
|
+
* @param {number} value - New sensor value
|
|
342
|
+
*/
|
|
343
|
+
async handleSensorUpdate(type, meterName, sensorDP, value) {
|
|
344
|
+
if (typeof value !== 'number' || value < 0) {
|
|
345
|
+
this.adapter.log.warn(`Invalid sensor value for ${type}.${meterName}: ${value}`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
|
|
350
|
+
this.adapter.log.debug(`Sensor update for ${basePath}: ${value}`);
|
|
351
|
+
|
|
352
|
+
// Get meter config
|
|
353
|
+
const meters = this.getMetersForType(type);
|
|
354
|
+
const meter = meters.find(m => m.name === meterName);
|
|
355
|
+
if (!meter) {
|
|
356
|
+
this.adapter.log.warn(`Meter ${type}.${meterName} not found in configuration`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const config = meter.config;
|
|
361
|
+
const now = Date.now();
|
|
362
|
+
let consumption = value;
|
|
363
|
+
let consumptionM3 = null;
|
|
364
|
+
|
|
365
|
+
this.adapter.log.debug(`[${basePath}] Sensor update: raw=${value}, offset=${config.offset}`);
|
|
366
|
+
|
|
367
|
+
// Apply offset FIRST
|
|
368
|
+
if (config.offset !== 0) {
|
|
369
|
+
consumption = consumption - config.offset;
|
|
370
|
+
this.adapter.log.debug(`[${basePath}] After offset: ${consumption}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// For gas, convert m³ to kWh
|
|
374
|
+
if (type === 'gas') {
|
|
375
|
+
const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
|
|
376
|
+
const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
|
|
377
|
+
consumptionM3 = consumption;
|
|
378
|
+
await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumption, true);
|
|
379
|
+
consumption = calculator.convertGasM3ToKWh(consumption, brennwert, zZahl);
|
|
380
|
+
consumption = calculator.roundToDecimals(consumption, 2);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Update meter reading
|
|
384
|
+
await this.adapter.setStateAsync(`${basePath}.info.meterReading`, consumption, true);
|
|
385
|
+
|
|
386
|
+
// Calculate deltas
|
|
387
|
+
const lastValue = this.lastSensorValues[sensorDP];
|
|
388
|
+
this.lastSensorValues[sensorDP] = consumption;
|
|
389
|
+
|
|
390
|
+
if (lastValue === undefined || consumption <= lastValue) {
|
|
391
|
+
if (lastValue !== undefined && consumption < lastValue) {
|
|
392
|
+
this.adapter.log.warn(
|
|
393
|
+
`${type}.${meterName}: Sensor value decreased (${lastValue} -> ${consumption}). Assuming meter reset.`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
await this.updateCosts(type, meterName, config);
|
|
397
|
+
await this.updateTotalCosts(type);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const delta = consumption - lastValue;
|
|
402
|
+
this.adapter.log.debug(`${type}.${meterName} delta: ${delta}`);
|
|
403
|
+
|
|
404
|
+
// Track volume for gas
|
|
405
|
+
if (type === 'gas') {
|
|
406
|
+
const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
|
|
407
|
+
const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
|
|
408
|
+
const deltaVolume = delta / (brennwert * zZahl);
|
|
409
|
+
|
|
410
|
+
const dailyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.dailyVolume`);
|
|
411
|
+
const monthlyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.monthlyVolume`);
|
|
412
|
+
const yearlyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.yearlyVolume`);
|
|
413
|
+
|
|
414
|
+
await this.adapter.setStateAsync(
|
|
415
|
+
`${basePath}.consumption.dailyVolume`,
|
|
416
|
+
calculator.roundToDecimals((dailyVolume?.val || 0) + deltaVolume, 2),
|
|
417
|
+
true,
|
|
418
|
+
);
|
|
419
|
+
await this.adapter.setStateAsync(
|
|
420
|
+
`${basePath}.consumption.monthlyVolume`,
|
|
421
|
+
calculator.roundToDecimals((monthlyVolume?.val || 0) + deltaVolume, 2),
|
|
422
|
+
true,
|
|
423
|
+
);
|
|
424
|
+
await this.adapter.setStateAsync(
|
|
425
|
+
`${basePath}.consumption.yearlyVolume`,
|
|
426
|
+
calculator.roundToDecimals((yearlyVolume?.val || 0) + deltaVolume, 3),
|
|
427
|
+
true,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Update consumption values
|
|
432
|
+
const dailyState = await this.adapter.getStateAsync(`${basePath}.consumption.daily`);
|
|
433
|
+
await this.adapter.setStateAsync(
|
|
434
|
+
`${basePath}.consumption.daily`,
|
|
435
|
+
calculator.roundToDecimals((dailyState?.val || 0) + delta, 2),
|
|
436
|
+
true,
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
const monthlyState = await this.adapter.getStateAsync(`${basePath}.consumption.monthly`);
|
|
440
|
+
await this.adapter.setStateAsync(
|
|
441
|
+
`${basePath}.consumption.monthly`,
|
|
442
|
+
calculator.roundToDecimals((monthlyState?.val || 0) + delta, 2),
|
|
443
|
+
true,
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
// Yearly consumption
|
|
447
|
+
if (config.initialReading > 0) {
|
|
448
|
+
let yearlyAmount;
|
|
449
|
+
if (type === 'gas') {
|
|
450
|
+
const yearlyM3 = Math.max(0, (consumptionM3 || 0) - config.initialReading);
|
|
451
|
+
await this.adapter.setStateAsync(
|
|
452
|
+
`${basePath}.consumption.yearlyVolume`,
|
|
453
|
+
calculator.roundToDecimals(yearlyM3, 2),
|
|
454
|
+
true,
|
|
455
|
+
);
|
|
456
|
+
const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
|
|
457
|
+
const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
|
|
458
|
+
yearlyAmount = calculator.convertGasM3ToKWh(yearlyM3, brennwert, zZahl);
|
|
459
|
+
} else {
|
|
460
|
+
yearlyAmount = Math.max(0, consumption - config.initialReading);
|
|
461
|
+
}
|
|
462
|
+
await this.adapter.setStateAsync(
|
|
463
|
+
`${basePath}.consumption.yearly`,
|
|
464
|
+
calculator.roundToDecimals(yearlyAmount, 2),
|
|
465
|
+
true,
|
|
466
|
+
);
|
|
467
|
+
} else {
|
|
468
|
+
const yState = await this.adapter.getStateAsync(`${basePath}.consumption.yearly`);
|
|
469
|
+
await this.adapter.setStateAsync(
|
|
470
|
+
`${basePath}.consumption.yearly`,
|
|
471
|
+
calculator.roundToDecimals((yState?.val || 0) + delta, 2),
|
|
472
|
+
true,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
await this.updateCosts(type, meterName, config);
|
|
477
|
+
await this.updateTotalCosts(type);
|
|
478
|
+
|
|
479
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
|
|
480
|
+
await this.adapter.setStateAsync(`${basePath}.info.lastSync`, now, true);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Updates the current price display
|
|
485
|
+
*
|
|
486
|
+
* @param {string} type - Utility type
|
|
487
|
+
* @param {string} meterName - Meter name
|
|
488
|
+
* @param {object} config - Meter configuration
|
|
489
|
+
*/
|
|
490
|
+
async updateCurrentPrice(type, meterName, config) {
|
|
491
|
+
const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
|
|
492
|
+
const configType = this.getConfigType(type);
|
|
493
|
+
|
|
494
|
+
let tariffName = 'Standard';
|
|
495
|
+
let activePrice = config.preis || 0;
|
|
496
|
+
|
|
497
|
+
// Only main meter supports HT/NT
|
|
498
|
+
if (meterName === 'main' && config.htNtEnabled) {
|
|
499
|
+
const isHT = calculator.isHTTime(this.adapter.config, configType);
|
|
500
|
+
if (isHT) {
|
|
501
|
+
activePrice = this.adapter.config[`${configType}HtPrice`] || 0;
|
|
502
|
+
tariffName = 'Haupttarif (HT)';
|
|
503
|
+
} else {
|
|
504
|
+
activePrice = this.adapter.config[`${configType}NtPrice`] || 0;
|
|
505
|
+
tariffName = 'Nebentarif (NT)';
|
|
506
|
+
}
|
|
507
|
+
this.adapter.log.debug(`[${basePath}] Price update: tariff=${tariffName}, price=${activePrice}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
await this.adapter.setStateAsync(
|
|
511
|
+
`${basePath}.info.currentPrice`,
|
|
512
|
+
calculator.roundToDecimals(activePrice, 4),
|
|
513
|
+
true,
|
|
514
|
+
);
|
|
515
|
+
await this.adapter.setStateAsync(`${basePath}.info.currentTariff`, tariffName, true);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Updates costs for a specific meter
|
|
520
|
+
*
|
|
521
|
+
* @param {string} type - Utility type
|
|
522
|
+
* @param {string} meterName - Meter name
|
|
523
|
+
* @param {object} config - Meter configuration
|
|
524
|
+
*/
|
|
525
|
+
async updateCosts(type, meterName, config) {
|
|
526
|
+
const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
|
|
527
|
+
|
|
528
|
+
// Get consumption values
|
|
529
|
+
const daily = (await this.adapter.getStateAsync(`${basePath}.consumption.daily`))?.val || 0;
|
|
530
|
+
const monthly = (await this.adapter.getStateAsync(`${basePath}.consumption.monthly`))?.val || 0;
|
|
531
|
+
const yearly = (await this.adapter.getStateAsync(`${basePath}.consumption.yearly`))?.val || 0;
|
|
532
|
+
|
|
533
|
+
// Get price
|
|
534
|
+
const price = config.preis || 0;
|
|
535
|
+
|
|
536
|
+
this.adapter.log.debug(
|
|
537
|
+
`[${basePath}] Cost update: daily=${daily}, monthly=${monthly}, yearly=${yearly}, price=${price}`,
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
// Calculate consumption costs
|
|
541
|
+
const dailyCost = daily * price;
|
|
542
|
+
const monthlyCost = monthly * price;
|
|
543
|
+
const yearlyCost = yearly * price;
|
|
544
|
+
|
|
545
|
+
await this.adapter.setStateAsync(`${basePath}.costs.daily`, calculator.roundToDecimals(dailyCost, 2), true);
|
|
546
|
+
await this.adapter.setStateAsync(`${basePath}.costs.monthly`, calculator.roundToDecimals(monthlyCost, 2), true);
|
|
547
|
+
await this.adapter.setStateAsync(`${basePath}.costs.yearly`, calculator.roundToDecimals(yearlyCost, 2), true);
|
|
548
|
+
|
|
549
|
+
await this.adapter.setStateAsync(`${basePath}.costs.basicCharge`, Number(config.grundgebuehr) || 0, true);
|
|
550
|
+
|
|
551
|
+
// Calculate annual fee (prorated)
|
|
552
|
+
const yearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastYearStart`);
|
|
553
|
+
let annualFeeAccumulated = 0;
|
|
554
|
+
|
|
555
|
+
if (yearStartState && yearStartState.val) {
|
|
556
|
+
const yearStartDate = calculator.parseDateString(yearStartState.val);
|
|
557
|
+
if (yearStartDate && !isNaN(yearStartDate.getTime())) {
|
|
558
|
+
const now = new Date();
|
|
559
|
+
const yearStartTime = yearStartDate.getTime();
|
|
560
|
+
const nowTime = now.getTime();
|
|
561
|
+
const daysSinceYearStart = Math.floor((nowTime - yearStartTime) / (1000 * 60 * 60 * 24));
|
|
562
|
+
const daysInYear = calculator.isLeapYear(now.getFullYear()) ? 366 : 365;
|
|
563
|
+
annualFeeAccumulated = ((config.jahresgebuehr || 0) / daysInYear) * daysSinceYearStart;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
await this.adapter.setStateAsync(
|
|
568
|
+
`${basePath}.costs.annualFee`,
|
|
569
|
+
calculator.roundToDecimals(annualFeeAccumulated, 2),
|
|
570
|
+
true,
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
// Calculate balance and total yearly costs
|
|
574
|
+
if (yearStartState && yearStartState.val) {
|
|
575
|
+
const yearStartDate = calculator.parseDateString(yearStartState.val);
|
|
576
|
+
if (yearStartDate && !isNaN(yearStartDate.getTime())) {
|
|
577
|
+
const now = new Date();
|
|
578
|
+
// Calculate paid total based on started months (not just completed months)
|
|
579
|
+
// If current month has started, count it as paid
|
|
580
|
+
const monthsSinceYearStart = calculator.getMonthsDifference(yearStartDate, now) + 1;
|
|
581
|
+
|
|
582
|
+
// Calculate total yearly costs with correct months
|
|
583
|
+
const basicChargeAccumulated = (config.grundgebuehr || 0) * monthsSinceYearStart;
|
|
584
|
+
const totalYearlyCost = yearlyCost + basicChargeAccumulated + annualFeeAccumulated;
|
|
585
|
+
|
|
586
|
+
await this.adapter.setStateAsync(
|
|
587
|
+
`${basePath}.costs.totalYearly`,
|
|
588
|
+
calculator.roundToDecimals(totalYearlyCost, 2),
|
|
589
|
+
true,
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const paidTotal = (config.abschlag || 0) * monthsSinceYearStart;
|
|
593
|
+
const balance = paidTotal - totalYearlyCost;
|
|
594
|
+
|
|
595
|
+
this.adapter.log.debug(
|
|
596
|
+
`[${basePath}] Balance calculation: abschlag=${config.abschlag}, months=${monthsSinceYearStart}, paidTotal=${paidTotal.toFixed(2)}, totalYearly=${totalYearlyCost.toFixed(2)}, balance=${balance.toFixed(2)}`,
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
await this.adapter.setStateAsync(
|
|
600
|
+
`${basePath}.costs.paidTotal`,
|
|
601
|
+
calculator.roundToDecimals(paidTotal, 2),
|
|
602
|
+
true,
|
|
603
|
+
);
|
|
604
|
+
await this.adapter.setStateAsync(
|
|
605
|
+
`${basePath}.costs.balance`,
|
|
606
|
+
calculator.roundToDecimals(balance, 2),
|
|
607
|
+
true,
|
|
608
|
+
);
|
|
609
|
+
} else {
|
|
610
|
+
// Fallback if yearStartDate parsing fails
|
|
611
|
+
const totalYearlyCost = yearlyCost + annualFeeAccumulated;
|
|
612
|
+
await this.adapter.setStateAsync(
|
|
613
|
+
`${basePath}.costs.totalYearly`,
|
|
614
|
+
calculator.roundToDecimals(totalYearlyCost, 2),
|
|
615
|
+
true,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
// Fallback if no yearStartState exists
|
|
620
|
+
const totalYearlyCost = yearlyCost + annualFeeAccumulated;
|
|
621
|
+
await this.adapter.setStateAsync(
|
|
622
|
+
`${basePath}.costs.totalYearly`,
|
|
623
|
+
calculator.roundToDecimals(totalYearlyCost, 2),
|
|
624
|
+
true,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Updates total costs (sum of all meters)
|
|
631
|
+
*
|
|
632
|
+
* @param {string} type - Utility type
|
|
633
|
+
*/
|
|
634
|
+
async updateTotalCosts(type) {
|
|
635
|
+
const meters = this.getMetersForType(type);
|
|
636
|
+
|
|
637
|
+
if (meters.length <= 1) {
|
|
638
|
+
// No totals needed for single meter
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
let totalDaily = 0;
|
|
643
|
+
let totalMonthly = 0;
|
|
644
|
+
let totalYearly = 0;
|
|
645
|
+
let totalCostsDaily = 0;
|
|
646
|
+
let totalCostsMonthly = 0;
|
|
647
|
+
let totalCostsYearly = 0;
|
|
648
|
+
|
|
649
|
+
for (const meter of meters) {
|
|
650
|
+
const basePath = meter.name === 'main' ? type : `${type}.${meter.name}`;
|
|
651
|
+
|
|
652
|
+
totalDaily += (await this.adapter.getStateAsync(`${basePath}.consumption.daily`))?.val || 0;
|
|
653
|
+
totalMonthly += (await this.adapter.getStateAsync(`${basePath}.consumption.monthly`))?.val || 0;
|
|
654
|
+
totalYearly += (await this.adapter.getStateAsync(`${basePath}.consumption.yearly`))?.val || 0;
|
|
655
|
+
|
|
656
|
+
totalCostsDaily += (await this.adapter.getStateAsync(`${basePath}.costs.daily`))?.val || 0;
|
|
657
|
+
totalCostsMonthly += (await this.adapter.getStateAsync(`${basePath}.costs.monthly`))?.val || 0;
|
|
658
|
+
totalCostsYearly += (await this.adapter.getStateAsync(`${basePath}.costs.totalYearly`))?.val || 0;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
await this.adapter.setStateAsync(
|
|
662
|
+
`${type}.totals.consumption.daily`,
|
|
663
|
+
calculator.roundToDecimals(totalDaily, 2),
|
|
664
|
+
true,
|
|
665
|
+
);
|
|
666
|
+
await this.adapter.setStateAsync(
|
|
667
|
+
`${type}.totals.consumption.monthly`,
|
|
668
|
+
calculator.roundToDecimals(totalMonthly, 2),
|
|
669
|
+
true,
|
|
670
|
+
);
|
|
671
|
+
await this.adapter.setStateAsync(
|
|
672
|
+
`${type}.totals.consumption.yearly`,
|
|
673
|
+
calculator.roundToDecimals(totalYearly, 2),
|
|
674
|
+
true,
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
await this.adapter.setStateAsync(
|
|
678
|
+
`${type}.totals.costs.daily`,
|
|
679
|
+
calculator.roundToDecimals(totalCostsDaily, 2),
|
|
680
|
+
true,
|
|
681
|
+
);
|
|
682
|
+
await this.adapter.setStateAsync(
|
|
683
|
+
`${type}.totals.costs.monthly`,
|
|
684
|
+
calculator.roundToDecimals(totalCostsMonthly, 2),
|
|
685
|
+
true,
|
|
686
|
+
);
|
|
687
|
+
await this.adapter.setStateAsync(
|
|
688
|
+
`${type}.totals.costs.totalYearly`,
|
|
689
|
+
calculator.roundToDecimals(totalCostsYearly, 2),
|
|
690
|
+
true,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Cleanup removed meters
|
|
696
|
+
*
|
|
697
|
+
* @param {string} type - Utility type
|
|
698
|
+
* @param {Array} currentMeters - Currently configured meters
|
|
699
|
+
*/
|
|
700
|
+
async cleanupRemovedMeters(type, currentMeters) {
|
|
701
|
+
// Get all existing meter folders
|
|
702
|
+
try {
|
|
703
|
+
const obj = await this.adapter.getObjectAsync(type);
|
|
704
|
+
if (!obj) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const children = await this.adapter.getObjectListAsync({
|
|
709
|
+
startkey: `${this.adapter.namespace}.${type}.`,
|
|
710
|
+
endkey: `${this.adapter.namespace}.${type}.\u9999`,
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
const PROTECTED_CATEGORIES = [
|
|
714
|
+
'consumption',
|
|
715
|
+
'costs',
|
|
716
|
+
'billing',
|
|
717
|
+
'info',
|
|
718
|
+
'statistics',
|
|
719
|
+
'adjustment',
|
|
720
|
+
'history',
|
|
721
|
+
'totals',
|
|
722
|
+
];
|
|
723
|
+
const currentMeterNames = new Set(currentMeters.map(m => m.name));
|
|
724
|
+
const existingMeterFolders = new Set();
|
|
725
|
+
|
|
726
|
+
// Find all meter folders
|
|
727
|
+
for (const item of children.rows) {
|
|
728
|
+
const id = item.id.replace(`${this.adapter.namespace}.`, '');
|
|
729
|
+
const parts = id.split('.');
|
|
730
|
+
// Only consider it a meter folder if it's the second part and not a protected category
|
|
731
|
+
if (parts.length >= 2 && parts[0] === type && !PROTECTED_CATEGORIES.includes(parts[1])) {
|
|
732
|
+
existingMeterFolders.add(parts[1]);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Delete meters that no longer exist in config
|
|
737
|
+
for (const folderName of existingMeterFolders) {
|
|
738
|
+
if (!currentMeterNames.has(folderName)) {
|
|
739
|
+
this.adapter.log.info(`Removing deleted meter: ${type}.${folderName}`);
|
|
740
|
+
await this.adapter.delObjectAsync(`${type}.${folderName}`, { recursive: true });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
} catch (error) {
|
|
744
|
+
this.adapter.log.warn(`Could not cleanup removed meters for ${type}: ${error.message}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
module.exports = MultiMeterManager;
|