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.
@@ -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;