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,339 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MessagingHandler handles all incoming adapter messages
|
|
5
|
+
* and outgoing notifications.
|
|
6
|
+
*/
|
|
7
|
+
class MessagingHandler {
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} adapter - ioBroker adapter instance
|
|
10
|
+
*/
|
|
11
|
+
constructor(adapter) {
|
|
12
|
+
this.adapter = adapter;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Is called when adapter receives message from config window.
|
|
17
|
+
*
|
|
18
|
+
* @param {Record<string, any>} obj - Message object from config
|
|
19
|
+
*/
|
|
20
|
+
async handleMessage(obj) {
|
|
21
|
+
if (!obj || !obj.command) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
this.adapter.log.debug(`[onMessage] Received command: ${obj.command} from ${obj.from}`);
|
|
26
|
+
|
|
27
|
+
if (obj.command === 'getInstances') {
|
|
28
|
+
try {
|
|
29
|
+
const instances = await this.adapter.getForeignObjectsAsync('system.adapter.*', 'instance');
|
|
30
|
+
const messengerTypes = [
|
|
31
|
+
'telegram',
|
|
32
|
+
'pushover',
|
|
33
|
+
'email',
|
|
34
|
+
'whatsapp',
|
|
35
|
+
'whatsapp-cmb',
|
|
36
|
+
'signal',
|
|
37
|
+
'signal-cmb',
|
|
38
|
+
'discord',
|
|
39
|
+
'notification-manager',
|
|
40
|
+
];
|
|
41
|
+
const result = [{ value: '', label: 'kein' }];
|
|
42
|
+
|
|
43
|
+
for (const id in instances) {
|
|
44
|
+
const parts = id.split('.');
|
|
45
|
+
const adapterName = parts[parts.length - 2];
|
|
46
|
+
if (messengerTypes.includes(adapterName)) {
|
|
47
|
+
const instanceName = id.replace('system.adapter.', '');
|
|
48
|
+
result.push({ value: instanceName, label: instanceName });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.adapter.sendTo(obj.from, obj.command, result, obj.callback);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
this.adapter.log.error(`Error in getInstances callback: ${error.message}`);
|
|
55
|
+
this.adapter.sendTo(obj.from, obj.command, [{ value: '', label: 'Fehler' }], obj.callback);
|
|
56
|
+
}
|
|
57
|
+
} else if (obj.command === 'testNotification') {
|
|
58
|
+
this.adapter.log.info(`[testNotification] Message data: ${JSON.stringify(obj.message)}`);
|
|
59
|
+
try {
|
|
60
|
+
let instance = obj.message?.instance;
|
|
61
|
+
|
|
62
|
+
// Handle cases where Admin UI doesn't resolve the placeholder ${data.notificationInstance}
|
|
63
|
+
if (!instance || instance.includes('${data.') || instance === 'none' || instance === 'kein') {
|
|
64
|
+
this.adapter.log.info('[testNotification] Using instance from saved configuration as fallback');
|
|
65
|
+
instance = this.adapter.config.notificationInstance;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!instance || instance === 'none' || instance === 'kein') {
|
|
69
|
+
this.adapter.sendTo(
|
|
70
|
+
obj.from,
|
|
71
|
+
obj.command,
|
|
72
|
+
{ error: 'Keine Instanz ausgewählt. Bitte auswählen und einmal SPEICHERN!' },
|
|
73
|
+
obj.callback,
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.adapter.log.info(`Sending test notification via ${instance}...`);
|
|
79
|
+
|
|
80
|
+
const testMsg =
|
|
81
|
+
'🔔 *Nebenkosten-Monitor Test*\n\nDiese Nachricht bestätigt, dass deine Benachrichtigungseinstellungen korrekt sind! 🚀';
|
|
82
|
+
|
|
83
|
+
// We wrap sendTo in a promise to capture success/error for the popup
|
|
84
|
+
const sendResult = await new Promise(resolve => {
|
|
85
|
+
const timeout = setTimeout(() => {
|
|
86
|
+
resolve({
|
|
87
|
+
error: `Timeout: ${instance} hat nicht rechtzeitig geantwortet. Ist der Adapter aktiv?`,
|
|
88
|
+
});
|
|
89
|
+
}, 10000);
|
|
90
|
+
|
|
91
|
+
this.adapter.sendTo(
|
|
92
|
+
instance,
|
|
93
|
+
'send',
|
|
94
|
+
{
|
|
95
|
+
text: testMsg,
|
|
96
|
+
message: testMsg,
|
|
97
|
+
parse_mode: 'Markdown',
|
|
98
|
+
},
|
|
99
|
+
res => {
|
|
100
|
+
clearTimeout(timeout);
|
|
101
|
+
this.adapter.log.info(
|
|
102
|
+
`[testNotification] Response from ${instance}: ${JSON.stringify(res)}`,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (res && (res.error || res.err)) {
|
|
106
|
+
resolve({ error: `Fehler von ${instance}: ${res.error || res.err}` });
|
|
107
|
+
} else if (
|
|
108
|
+
res &&
|
|
109
|
+
(res.sent ||
|
|
110
|
+
res.result === 'OK' ||
|
|
111
|
+
typeof res === 'string' ||
|
|
112
|
+
(res.response && res.response.includes('250')))
|
|
113
|
+
) {
|
|
114
|
+
// Specific handling for email (res.response contains SMTP code) and others
|
|
115
|
+
resolve({ result: `Erfolgreich! Antwort von ${instance}: ${JSON.stringify(res)}` });
|
|
116
|
+
} else {
|
|
117
|
+
// Fallback success if response is there but format unknown
|
|
118
|
+
resolve({ result: `Test-Nachricht an ${instance} übergeben.` });
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Respond to Admin UI - this triggers the popup
|
|
125
|
+
if (obj.callback) {
|
|
126
|
+
this.adapter.sendTo(obj.from, obj.command, sendResult, obj.callback);
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.adapter.log.error(`Failed to send test notification: ${error.message}`);
|
|
130
|
+
if (obj.callback) {
|
|
131
|
+
this.adapter.sendTo(
|
|
132
|
+
obj.from,
|
|
133
|
+
obj.command,
|
|
134
|
+
{ error: `Interner Fehler: ${error.message}` },
|
|
135
|
+
obj.callback,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
this.adapter.log.warn(`[onMessage] Unknown command: ${obj.command}`);
|
|
141
|
+
if (obj.callback) {
|
|
142
|
+
this.adapter.sendTo(obj.from, obj.command, { error: 'Unknown command' }, obj.callback);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Checks if any notifications need to be sent (reminders for billing period end or contract change)
|
|
149
|
+
*/
|
|
150
|
+
async checkNotifications() {
|
|
151
|
+
if (!this.adapter.config.notificationEnabled || !this.adapter.config.notificationInstance) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const types = ['gas', 'water', 'electricity', 'pv'];
|
|
156
|
+
const typesDe = { gas: 'Gas', water: 'Wasser', electricity: 'Strom', pv: 'PV' };
|
|
157
|
+
|
|
158
|
+
for (const type of types) {
|
|
159
|
+
const configType = this.adapter.consumptionManager.getConfigType(type);
|
|
160
|
+
const enabledKey = `notification${configType.charAt(0).toUpperCase() + configType.slice(1)}Enabled`;
|
|
161
|
+
|
|
162
|
+
if (!this.adapter.config[enabledKey] || !this.adapter.config[`${configType}Aktiv`]) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Get current days remaining
|
|
167
|
+
const daysRemainingState = await this.adapter.getStateAsync(`${type}.billing.daysRemaining`);
|
|
168
|
+
const daysRemaining = typeof daysRemainingState?.val === 'number' ? daysRemainingState.val : 999;
|
|
169
|
+
const periodEndState = await this.adapter.getStateAsync(`${type}.billing.periodEnd`);
|
|
170
|
+
const periodEnd = periodEndState?.val || '--.--.----';
|
|
171
|
+
|
|
172
|
+
// 1. BILLING END REMINDER (Zählerstand ablesen)
|
|
173
|
+
if (this.adapter.config.notificationBillingEnabled) {
|
|
174
|
+
const billingSent = await this.adapter.getStateAsync(`${type}.billing.notificationSent`);
|
|
175
|
+
const billingDaysThreshold = this.adapter.config.notificationBillingDays || 7;
|
|
176
|
+
|
|
177
|
+
if (billingSent?.val !== true && daysRemaining <= billingDaysThreshold) {
|
|
178
|
+
const message =
|
|
179
|
+
`🔔 *Nebenkosten-Monitor: Zählerstand ablesen*\n\n` +
|
|
180
|
+
`Dein Abrechnungszeitraum für *${typesDe[type]}* endet in ${daysRemaining} Tagen!\n\n` +
|
|
181
|
+
`📅 Datum: ${periodEnd}\n\n` +
|
|
182
|
+
`Bitte trage den Zählerstand rechtzeitig ein:\n` +
|
|
183
|
+
`1️⃣ Datenpunkt: ${type}.billing.endReading\n` +
|
|
184
|
+
`2️⃣ Zeitraum abschließen: ${type}.billing.closePeriod = true`;
|
|
185
|
+
|
|
186
|
+
await this.sendNotification(type, message, 'billing');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 2. CONTRACT CHANGE REMINDER (Tarif wechseln / Kündigungsfrist)
|
|
191
|
+
if (this.adapter.config.notificationChangeEnabled) {
|
|
192
|
+
const changeSent = await this.adapter.getStateAsync(`${type}.billing.notificationChangeSent`);
|
|
193
|
+
const changeDaysThreshold = this.adapter.config.notificationChangeDays || 60;
|
|
194
|
+
|
|
195
|
+
if (changeSent?.val !== true && daysRemaining <= changeDaysThreshold) {
|
|
196
|
+
const message =
|
|
197
|
+
`💡 *Nebenkosten-Monitor: Tarif-Check*\n\n` +
|
|
198
|
+
`Dein Vertrag für *${typesDe[type]}* endet am ${periodEnd}.\n\n` +
|
|
199
|
+
`⏰ Noch ${daysRemaining} Tage bis zum Ende des Zeitraums.\n\n` +
|
|
200
|
+
`Jetzt ist ein guter Zeitpunkt, um Preise zu vergleichen oder die Kündigungsfrist zu prüfen! 💸`;
|
|
201
|
+
|
|
202
|
+
await this.sendNotification(type, message, 'change');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await this.checkMonthlyReport();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Checks and sends monthly status report
|
|
212
|
+
*/
|
|
213
|
+
async checkMonthlyReport() {
|
|
214
|
+
if (!this.adapter.config.notificationMonthlyEnabled || !this.adapter.config.notificationInstance) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const today = new Date();
|
|
219
|
+
const configDay = this.adapter.config.notificationMonthlyDay || 1;
|
|
220
|
+
|
|
221
|
+
// Check if today is the configured day
|
|
222
|
+
if (today.getDate() !== configDay) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check if already sent today
|
|
227
|
+
const lastSentState = await this.adapter.getStateAsync('info.lastMonthlyReport');
|
|
228
|
+
const todayStr = today.toISOString().split('T')[0];
|
|
229
|
+
|
|
230
|
+
if (lastSentState?.val === todayStr) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Generate Report
|
|
235
|
+
let message = `📊 *Monats-Report* (${today.toLocaleDateString('de-DE')})\n\n`;
|
|
236
|
+
const types = ['electricity', 'gas', 'water', 'pv'];
|
|
237
|
+
const typesDe = { electricity: '⚡ Strom', gas: '🔥 Gas', water: '💧 Wasser', pv: '☀️ PV' };
|
|
238
|
+
let hasData = false;
|
|
239
|
+
|
|
240
|
+
for (const type of types) {
|
|
241
|
+
const configType = this.adapter.consumptionManager.getConfigType(type); // strom, gas, wasser, pv
|
|
242
|
+
|
|
243
|
+
if (!this.adapter.config[`${configType}Aktiv`]) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
hasData = true;
|
|
248
|
+
message += `*${typesDe[type]}*\\n`;
|
|
249
|
+
|
|
250
|
+
// Check if this is a multi-meter setup
|
|
251
|
+
const meters = this.adapter.multiMeterManager?.getMetersForType(type) || [];
|
|
252
|
+
const isMultiMeter = meters.length > 1;
|
|
253
|
+
|
|
254
|
+
// Consumption - use totals for multi-meter, main meter for single meter
|
|
255
|
+
let yearlyState, totalYearlyState, paidTotalState, balanceState;
|
|
256
|
+
|
|
257
|
+
if (isMultiMeter) {
|
|
258
|
+
// Multi-meter: use totals
|
|
259
|
+
yearlyState = await this.adapter.getStateAsync(`${type}.totals.consumption.yearly`);
|
|
260
|
+
totalYearlyState = await this.adapter.getStateAsync(`${type}.totals.costs.totalYearly`);
|
|
261
|
+
// Balance/paidTotal not available in totals, use main meter as representative
|
|
262
|
+
paidTotalState = await this.adapter.getStateAsync(`${type}.costs.paidTotal`);
|
|
263
|
+
balanceState = await this.adapter.getStateAsync(`${type}.costs.balance`);
|
|
264
|
+
message += `(${meters.length} Zähler gesamt)\\n`;
|
|
265
|
+
} else {
|
|
266
|
+
// Single meter: use main meter values
|
|
267
|
+
yearlyState = await this.adapter.getStateAsync(`${type}.consumption.yearly`);
|
|
268
|
+
totalYearlyState = await this.adapter.getStateAsync(`${type}.costs.totalYearly`);
|
|
269
|
+
paidTotalState = await this.adapter.getStateAsync(`${type}.costs.paidTotal`);
|
|
270
|
+
balanceState = await this.adapter.getStateAsync(`${type}.costs.balance`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let val = yearlyState?.val || 0;
|
|
274
|
+
// Round
|
|
275
|
+
val = Math.round(val * 100) / 100;
|
|
276
|
+
|
|
277
|
+
// Get unit
|
|
278
|
+
let displayUnit = 'kWh';
|
|
279
|
+
if (type === 'water') {
|
|
280
|
+
displayUnit = 'm³';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
message += `Verbrauch (Jahr): ${val} ${displayUnit}\\n`;
|
|
284
|
+
|
|
285
|
+
// Costs
|
|
286
|
+
const cost = (totalYearlyState?.val || 0).toFixed(2);
|
|
287
|
+
message += `Verbrauchs-Kosten: ${cost} €\\n`;
|
|
288
|
+
|
|
289
|
+
// Only show balance if Abschlag is configured (paidTotal > 0)
|
|
290
|
+
const paid = paidTotalState?.val || 0;
|
|
291
|
+
if (paid > 0) {
|
|
292
|
+
const balance = balanceState?.val || 0;
|
|
293
|
+
const balanceStr = balance.toFixed(2);
|
|
294
|
+
const status = balance > 0 ? '❌ Nachzahlung' : '✅ Guthaben';
|
|
295
|
+
|
|
296
|
+
message += `Bezahlt: ${paid.toFixed(2)} €\\n`;
|
|
297
|
+
message += `Saldo: *${balanceStr} €* (${status})\\n`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
message += `\\n`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (hasData) {
|
|
304
|
+
await this.sendNotification('system', message, 'report');
|
|
305
|
+
// Update state to prevent resending
|
|
306
|
+
await this.adapter.setStateAsync('info.lastMonthlyReport', todayStr, true);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Helper to send notification and mark as sent
|
|
312
|
+
*
|
|
313
|
+
* @param {string} type - gas, water, electricity
|
|
314
|
+
* @param {string} message - Message text
|
|
315
|
+
* @param {string} reminderType - billing, change, or report
|
|
316
|
+
*/
|
|
317
|
+
async sendNotification(type, message, reminderType) {
|
|
318
|
+
try {
|
|
319
|
+
const instance = this.adapter.config.notificationInstance;
|
|
320
|
+
this.adapter.log.info(`Sending ${reminderType} notification for ${type} via ${instance}`);
|
|
321
|
+
|
|
322
|
+
await this.adapter.sendToAsync(instance, 'send', {
|
|
323
|
+
text: message,
|
|
324
|
+
message: message,
|
|
325
|
+
parse_mode: 'Markdown',
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Mark as sent (only for billing/change)
|
|
329
|
+
if (reminderType !== 'report') {
|
|
330
|
+
const stateKey = reminderType === 'change' ? 'notificationChangeSent' : 'notificationSent';
|
|
331
|
+
await this.adapter.setStateAsync(`${type}.billing.${stateKey}`, true, true);
|
|
332
|
+
}
|
|
333
|
+
} catch (error) {
|
|
334
|
+
this.adapter.log.error(`Failed to send ${reminderType} notification for ${type}: ${error.message}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = MessagingHandler;
|