iobroker.poolcontrol 0.7.2 → 0.8.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.
@@ -0,0 +1,1116 @@
1
+ 'use strict';
2
+ /* eslint-disable jsdoc/require-param-description */
3
+ /* eslint-disable jsdoc/require-returns-description */
4
+
5
+ /**
6
+ * aiHelper
7
+ * --------------------------------------------------------------
8
+ * Zentraler KI-Helper für PoolControl.
9
+ *
10
+ * Nutzt die States aus aiStates.js:
11
+ * ai.weather.switches.*
12
+ * ai.weather.schedule.*
13
+ * ai.weather.outputs.*
14
+ *
15
+ * Funktionen:
16
+ * - Liest Geodaten aus system.config (Latitude/Longitude)
17
+ * - Ruft Wetterdaten von Open-Meteo ab (bei Bedarf, max. 4x/Tag – je Modul)
18
+ * - Erzeugt Textausgaben:
19
+ * ai.weather.outputs.weather_advice
20
+ * ai.weather.outputs.daily_summary
21
+ * ai.weather.outputs.pool_tips
22
+ * ai.weather.outputs.weekend_summary
23
+ * ai.weather.outputs.last_message
24
+ * - Optional: legt Texte in speech.queue für Sprachausgabe
25
+ *
26
+ * Wichtige Schalter:
27
+ * - ai.enabled → globaler KI-Schalter
28
+ * - ai.weather.switches.allow_speech → Sprachausgabe erlaubt
29
+ * - ai.weather.switches.weather_advice_enabled
30
+ * - ai.weather.switches.daily_summary_enabled
31
+ * - ai.weather.switches.daily_pool_tips_enabled
32
+ * - ai.weather.switches.weekend_summary_enabled
33
+ * - ai.weather.switches.debug_mode
34
+ *
35
+ * Zeitsteuerung (HH:MM, lokal):
36
+ * - ai.weather.schedule.weather_advice_time
37
+ * - ai.weather.schedule.daily_summary_time
38
+ * - ai.weather.schedule.daily_pool_tips_time
39
+ * - ai.weather.schedule.weekend_summary_time
40
+ */
41
+
42
+ const https = require('https');
43
+
44
+ const aiHelper = {
45
+ adapter: null,
46
+ timers: [],
47
+ _lastScheduleValues: {}, // NEU: merkt sich letzte Zeitwerte
48
+ _debugMode: false,
49
+
50
+ // Anti-Spam-Level (merkt sich letzte Warnungen)
51
+ _lastPoolTipCode: null,
52
+ _lastPoolTipWindLevel: null,
53
+ _lastPoolTipTimestamp: 0,
54
+
55
+ /**
56
+ * Initialisiert den AI-Helper (Timer + Grundkonfiguration).
57
+ *
58
+ * @param {import('iobroker').Adapter} adapter
59
+ */
60
+ async init(adapter) {
61
+ this.adapter = adapter;
62
+ this.adapter.log.info('[aiHelper] Initialisierung gestartet');
63
+
64
+ // Ersten Settings-Load + Timeraufbau
65
+ await this._refreshTimers();
66
+
67
+ this.adapter.log.info('[aiHelper] Initialisierung abgeschlossen');
68
+ },
69
+
70
+ /**
71
+ * Aufräumen beim Adapter-Stop.
72
+ */
73
+ cleanup() {
74
+ this._clearTimers();
75
+ this.adapter && this.adapter.log.debug('[aiHelper] Cleanup abgeschlossen (Timer gestoppt)');
76
+ },
77
+
78
+ /**
79
+ * Reagiert auf State-Änderungen (ai.switches.*, ai.schedule.*),
80
+ * damit Schalter und Zeiten ohne Neustart wirksam werden.
81
+ *
82
+ * @param {string} id
83
+ * @param {ioBroker.State | null} state
84
+ */
85
+ async handleStateChange(id, state) {
86
+ if (!this.adapter) {
87
+ return;
88
+ }
89
+ if (!state) {
90
+ return;
91
+ }
92
+
93
+ // Änderungen vom Adapter selbst ignorieren
94
+ if (state.from && state.from.startsWith(`system.adapter.${this.adapter.name}.`)) {
95
+ return;
96
+ }
97
+
98
+ // ---------------------------------------------------------
99
+ // FIX 1: Nur AI-States weiterverarbeiten
100
+ // ---------------------------------------------------------
101
+ if (!id.includes('.ai.')) {
102
+ // Alle Nicht-AI-States ignorieren → verhindert Log-Flut
103
+ return;
104
+ }
105
+
106
+ // ---------------------------------------------------------
107
+ // FIX 2: Uhrzeitänderungen IMMER erkennen und loggen
108
+ // ---------------------------------------------------------
109
+ if (id.includes('.ai.weather.schedule.')) {
110
+ const oldVal = this._lastScheduleValues[id];
111
+ const newVal = state.val;
112
+
113
+ this.adapter.log.info(
114
+ `[aiHelper] Uhrzeit geändert: ${id}: ${oldVal || '(kein vorheriger Wert)'} → ${newVal}`,
115
+ );
116
+
117
+ // neuen Wert speichern
118
+ this._lastScheduleValues[id] = newVal;
119
+
120
+ // Timer neu aufbauen
121
+ await this._refreshTimers();
122
+
123
+ // NEU: Wenn Uhrzeit heute noch in der Zukunft liegt → sofort ausführen
124
+ try {
125
+ const now = new Date();
126
+ const [hourStr, minuteStr] = String(newVal).split(':');
127
+ const hour = Number(hourStr);
128
+ const minute = Number(minuteStr);
129
+
130
+ if (!Number.isNaN(hour) && !Number.isNaN(minute)) {
131
+ const target = new Date();
132
+ target.setHours(hour, minute, 0, 0);
133
+
134
+ // Nur wenn Zielzeit HEUTE noch bevorsteht
135
+ if (target > now) {
136
+ this.adapter.log.info(
137
+ `[aiHelper] Neue Uhrzeit liegt heute noch in der Zukunft → führe Modul sofort aus (${id})`,
138
+ );
139
+
140
+ if (id.endsWith('weather_advice_time')) {
141
+ await this._runWeatherAdvice();
142
+ }
143
+ if (id.endsWith('daily_summary_time')) {
144
+ await this._runDailySummary();
145
+ }
146
+ if (id.endsWith('daily_pool_tips_time')) {
147
+ await this._runDailyPoolTips();
148
+ }
149
+ if (id.endsWith('weekend_summary_time')) {
150
+ await this._runWeekendSummary();
151
+ }
152
+ }
153
+ }
154
+ } catch (e) {
155
+ this.adapter.log.warn(`[aiHelper] Fehler bei Sofortausführung nach Zeitänderung: ${e.message}`);
156
+ }
157
+
158
+ return;
159
+ }
160
+
161
+ // ---------------------------------------------------------
162
+ // FIX 3: Schalteränderungen immer melden
163
+ // ---------------------------------------------------------
164
+ if (id.includes('.ai.weather.switches.')) {
165
+ this.adapter.log.info(`[aiHelper] Schalter geändert: ${id} = ${state.val} – Timer werden neu aufgebaut`);
166
+
167
+ await this._refreshTimers();
168
+ return;
169
+ }
170
+ },
171
+
172
+ // ---------------------------------------------------------------------
173
+ // Timer-Verwaltung
174
+ // ---------------------------------------------------------------------
175
+
176
+ /**
177
+ * Bestehende Timer stoppen.
178
+ */
179
+ _clearTimers() {
180
+ for (const t of this.timers) {
181
+ clearInterval(t);
182
+ }
183
+ this.timers = [];
184
+ },
185
+
186
+ /**
187
+ * Liest alle relevanten States und baut die Timer neu auf.
188
+ */
189
+ async _refreshTimers() {
190
+ this._clearTimers();
191
+
192
+ const aiEnabled = await this._getBool('ai.enabled', false);
193
+ this._debugMode = await this._getBool('ai.weather.switches.debug_mode', false);
194
+
195
+ if (!aiEnabled) {
196
+ this.adapter.log.info('[aiHelper] KI ist deaktiviert (ai.enabled = false) – keine Timer aktiv');
197
+ return;
198
+ }
199
+
200
+ this.adapter.log.info('[aiHelper] KI ist aktiv – Timer werden gesetzt');
201
+
202
+ // --- Wetterhinweise ---
203
+ const weatherEnabled = await this._getBool('ai.weather.switches.weather_advice_enabled', false);
204
+ if (weatherEnabled) {
205
+ const time = await this._getTimeOrDefault('ai.weather.schedule.weather_advice_time', '08:00');
206
+ this._createDailyTimer(time, async () => {
207
+ await this._runWeatherAdvice();
208
+ });
209
+ this.adapter.log.debug(
210
+ `[aiHelper] Wetterhinweis-Timer gesetzt für ${time.hour}:${String(time.minute).padStart(2, '0')}`,
211
+ );
212
+ }
213
+
214
+ // --- Tägliche Zusammenfassung ---
215
+ const summaryEnabled = await this._getBool('ai.weather.switches.daily_summary_enabled', false);
216
+ if (summaryEnabled) {
217
+ const time = await this._getTimeOrDefault('ai.weather.schedule.daily_summary_time', '09:00');
218
+ this._createDailyTimer(time, async () => {
219
+ await this._runDailySummary();
220
+ });
221
+ this.adapter.log.debug(
222
+ `[aiHelper] Daily-Summary-Timer gesetzt für ${time.hour}:${String(time.minute).padStart(2, '0')}`,
223
+ );
224
+ }
225
+
226
+ // --- Tägliche Pool-Tipps ---
227
+ const tipsEnabled = await this._getBool('ai.weather.switches.daily_pool_tips_enabled', false);
228
+ if (tipsEnabled) {
229
+ const time = await this._getTimeOrDefault('ai.weather.schedule.daily_pool_tips_time', '10:00');
230
+ this._createDailyTimer(time, async () => {
231
+ await this._runDailyPoolTips();
232
+ });
233
+ this.adapter.log.debug(
234
+ `[aiHelper] Pool-Tipps-Timer gesetzt für ${time.hour}:${String(time.minute).padStart(2, '0')}`,
235
+ );
236
+ }
237
+
238
+ // --- Wochenend-Zusammenfassung ---
239
+ const weekendEnabled = await this._getBool('ai.weather.switches.weekend_summary_enabled', false);
240
+ if (weekendEnabled) {
241
+ const time = await this._getTimeOrDefault('ai.weather.schedule.weekend_summary_time', '18:00');
242
+ this._createDailyTimer(time, async () => {
243
+ await this._runWeekendSummary();
244
+ });
245
+ this.adapter.log.debug(
246
+ `[aiHelper] Wochenend-Timer gesetzt für ${time.hour}:${String(time.minute).padStart(2, '0')}`,
247
+ );
248
+ }
249
+
250
+ //--------------------------------------------------------
251
+ // NEU: Stündlicher Wetter-Update-Timer
252
+ //--------------------------------------------------------
253
+ this.adapter.log.debug('[aiHelper] Stündlicher Wetter-Update-Timer wird gesetzt');
254
+
255
+ const hourlyTimer = setInterval(
256
+ async () => {
257
+ try {
258
+ const geo = await this._loadGeoLocation();
259
+ if (!geo) {
260
+ this.adapter.log.info('[aiHelper] Wetter-Update abgebrochen – keine Geodaten verfügbar');
261
+ return;
262
+ }
263
+
264
+ const weather = await this._fetchWeather(geo.lat, geo.lon);
265
+ if (!weather) {
266
+ this.adapter.log.info('[aiHelper] Wetter-Update abgebrochen – keine Wetterdaten verfügbar');
267
+ return;
268
+ }
269
+
270
+ // WeatherAdvice aktualisieren (heutiges Wetter)
271
+ const weatherText = this._buildWeatherAdviceText(weather);
272
+ await this._writeOutput('weather_advice', weatherText);
273
+
274
+ // NEU: Pool-Tipps automatisch mit aktualisieren
275
+ const seasonActive = await this._getBool('status.season_active', false);
276
+ const poolTipsText = this._buildPoolTipsText(weather, seasonActive);
277
+ await this._writeOutput('pool_tips', poolTipsText);
278
+
279
+ if (this._debugMode) {
280
+ this.adapter.log.debug(
281
+ '[aiHelper] Stündliches Wetter-Update durchgeführt (Weather + Pool-Tipps)',
282
+ );
283
+ }
284
+ } catch (err) {
285
+ this.adapter.log.warn(`[aiHelper] Fehler beim stündlichen Wetter-Update: ${err.message}`);
286
+ }
287
+ },
288
+ 60 * 60 * 1000,
289
+ ); // 1 Stunde
290
+
291
+ this.timers.push(hourlyTimer);
292
+ },
293
+
294
+ /**
295
+ * Erzeugt einen täglichen Timer für HH:MM (lokale Zeit).
296
+ * Prüft minütlich, ob die Zeit erreicht ist.
297
+ *
298
+ * @param {{hour:number,minute:number}} timeObj
299
+ * @param {() => Promise<void>} callback
300
+ */
301
+ _createDailyTimer(timeObj, callback) {
302
+ const { hour, minute } = timeObj;
303
+ const timer = setInterval(async () => {
304
+ const now = new Date();
305
+
306
+ // --- NEU: Nachträgliche Ausführung, falls Zeit knapp verpasst wurde ---
307
+ const diffMinutes = now.getHours() * 60 + now.getMinutes() - (hour * 60 + minute);
308
+
309
+ if (diffMinutes > 0 && diffMinutes <= 2) {
310
+ try {
311
+ await callback();
312
+ this.adapter.log.debug('[aiHelper] Zeit knapp verpasst – nachträgliche Ausführung durchgeführt');
313
+ } catch (err) {
314
+ this.adapter.log.warn(`[aiHelper] Fehler bei nachträglicher Ausführung: ${err.message}`);
315
+ }
316
+ }
317
+
318
+ if (now.getHours() === hour && now.getMinutes() === minute) {
319
+ this.adapter.log.debug(
320
+ `[aiHelper] Timer ausgelöst: ${hour}:${String(minute).padStart(2, '0')} → Callback wird ausgeführt`,
321
+ ); // NEU
322
+
323
+ try {
324
+ await callback();
325
+ } catch (err) {
326
+ this.adapter.log.warn(`[aiHelper] Fehler im Timer-Callback: ${err.message}`);
327
+ }
328
+ }
329
+ }, 60 * 1000); // jede Minute prüfen
330
+
331
+ this.timers.push(timer);
332
+ },
333
+
334
+ // ---------------------------------------------------------------------
335
+ // Hauptfunktionen – Module
336
+ // ---------------------------------------------------------------------
337
+
338
+ /**
339
+ * 1) Wetterhinweise (ai.outputs.weather_advice)
340
+ */
341
+ async _runWeatherAdvice() {
342
+ try {
343
+ const geo = await this._loadGeoLocation();
344
+ if (!geo) {
345
+ this.adapter.log.info('[aiHelper] Wetterhinweis abgebrochen – keine Geodaten verfügbar');
346
+ return;
347
+ }
348
+
349
+ const weather = await this._fetchWeather(geo.lat, geo.lon);
350
+ if (!weather) {
351
+ this.adapter.log.info('[aiHelper] Wetterhinweis abgebrochen – keine Wetterdaten');
352
+ return;
353
+ }
354
+
355
+ const text = this._buildWeatherAdviceText(weather);
356
+ await this._writeOutput('weather_advice', text);
357
+ await this._maybeSpeak(text);
358
+
359
+ this.adapter.log.info('[aiHelper] Neuer Wetterhinweis erzeugt');
360
+ } catch (err) {
361
+ this.adapter.log.warn(`[aiHelper] Fehler bei _runWeatherAdvice(): ${err.message}`);
362
+ }
363
+ },
364
+
365
+ /**
366
+ * 2) Tägliche Zusammenfassung (ai.outputs.daily_summary)
367
+ */
368
+ async _runDailySummary() {
369
+ try {
370
+ const geo = await this._loadGeoLocation();
371
+ const weather = geo ? await this._fetchWeather(geo.lat, geo.lon) : null;
372
+
373
+ const seasonActive = await this._getBool('status.season_active', false);
374
+ const pumpOn = await this._getBool('pump.pump_switch', false);
375
+ const pumpMode = await this._getString('pump.mode', 'auto');
376
+ const surfaceTemp = await this._getNumber('temperature.surface.current', null);
377
+
378
+ const text = this._buildDailySummaryText({
379
+ weather,
380
+ seasonActive,
381
+ pumpOn,
382
+ pumpMode,
383
+ surfaceTemp,
384
+ });
385
+
386
+ await this._writeOutput('daily_summary', text);
387
+ await this._maybeSpeak(text);
388
+
389
+ this.adapter.log.info('[aiHelper] Neue Tageszusammenfassung erzeugt');
390
+ } catch (err) {
391
+ this.adapter.log.warn(`[aiHelper] Fehler bei _runDailySummary(): ${err.message}`);
392
+ }
393
+ },
394
+
395
+ /**
396
+ * 3) Tägliche Pool-Tipps (ai.outputs.pool_tips)
397
+ */
398
+ async _runDailyPoolTips() {
399
+ try {
400
+ const geo = await this._loadGeoLocation();
401
+ const weather = geo ? await this._fetchWeather(geo.lat, geo.lon) : null;
402
+ const seasonActive = await this._getBool('status.season_active', false);
403
+
404
+ const text = this._buildPoolTipsText(weather, seasonActive);
405
+
406
+ await this._writeOutput('pool_tips', text);
407
+ await this._maybeSpeak(text);
408
+
409
+ this.adapter.log.info('[aiHelper] Neue Pool-Tipps erzeugt');
410
+ } catch (err) {
411
+ this.adapter.log.warn(`[aiHelper] Fehler bei _runDailyPoolTips(): ${err.message}`);
412
+ }
413
+ },
414
+
415
+ /**
416
+ * 4) Wochenend-Zusammenfassung (ai.outputs.weekend_summary)
417
+ */
418
+ async _runWeekendSummary() {
419
+ try {
420
+ const now = new Date();
421
+ const weekday = now.getDay(); // 0=So, 1=Mo, ..., 5=Fr, 6=Sa
422
+
423
+ // Nur Freitag oder Samstag sinnvoll
424
+ if (weekday !== 5 && weekday !== 6) {
425
+ this.adapter.log.info(
426
+ '[aiHelper] Wochenend-Zusammenfassung übersprungen – heute ist weder Freitag noch Samstag',
427
+ );
428
+ return;
429
+ }
430
+
431
+ const geo = await this._loadGeoLocation();
432
+ const weather = geo ? await this._fetchWeather(geo.lat, geo.lon) : null;
433
+ const seasonActive = await this._getBool('status.season_active', false);
434
+
435
+ const text = this._buildWeekendSummaryText(weather, seasonActive, weekday);
436
+
437
+ await this._writeOutput('weekend_summary', text);
438
+ await this._maybeSpeak(text);
439
+
440
+ this.adapter.log.info('[aiHelper] Neue Wochenend-Zusammenfassung erzeugt');
441
+ } catch (err) {
442
+ this.adapter.log.warn(`[aiHelper] Fehler bei _runWeekendSummary(): ${err.message}`);
443
+ }
444
+ },
445
+
446
+ //--------------------------------------------------------
447
+ // NEU: Stündliches Wetter-Update als eigene Funktion
448
+ //--------------------------------------------------------
449
+ async _runWeatherAutoUpdate() {
450
+ try {
451
+ const geo = await this._loadGeoLocation();
452
+ if (!geo) {
453
+ this.adapter.log.info('[aiHelper] Auto-Wetterupdate abgebrochen – keine Geodaten verfügbar');
454
+ return;
455
+ }
456
+
457
+ const weather = await this._fetchWeather(geo.lat, geo.lon);
458
+ if (!weather) {
459
+ this.adapter.log.info('[aiHelper] Auto-Wetterupdate abgebrochen – keine Wetterdaten verfügbar');
460
+ return;
461
+ }
462
+
463
+ // WeatherAdvice aktualisieren
464
+ const text = this._buildWeatherAdviceText(weather);
465
+ await this._writeOutput('weather_advice', text);
466
+
467
+ if (this._debugMode) {
468
+ this.adapter.log.debug('[aiHelper] Auto-Wetterupdate erfolgreich durchgeführt');
469
+ }
470
+ } catch (err) {
471
+ this.adapter.log.warn(`[aiHelper] Fehler bei Auto-Wetterupdate: ${err.message}`);
472
+ }
473
+ },
474
+
475
+ // ---------------------------------------------------------------------
476
+ // Geodaten + Wetter
477
+ // ---------------------------------------------------------------------
478
+
479
+ /**
480
+ * Lädt Geokoordinaten korrekt aus system.config.
481
+ *
482
+ * @returns {{lat:number,lon:number}|null}
483
+ */
484
+ async _loadGeoLocation() {
485
+ try {
486
+ const obj = await this.adapter.getForeignObjectAsync('system.config');
487
+ if (!obj || !obj.common) {
488
+ this.adapter.log.warn('[aiHelper] Konnte system.config nicht laden');
489
+ return null;
490
+ }
491
+
492
+ const lat = Number(obj.common.latitude);
493
+ const lon = Number(obj.common.longitude);
494
+
495
+ if (Number.isNaN(lat) || Number.isNaN(lon)) {
496
+ this.adapter.log.warn('[aiHelper] Geodaten ungültig – bitte in Admin unter System/Standort eintragen');
497
+ return null;
498
+ }
499
+
500
+ if (this._debugMode) {
501
+ this.adapter.log.debug(`[aiHelper] Geodaten geladen: lat=${lat}, lon=${lon}`);
502
+ }
503
+
504
+ return { lat, lon };
505
+ } catch (err) {
506
+ this.adapter.log.error(`[aiHelper] Fehler beim Laden der Geodaten: ${err.message}`);
507
+ return null;
508
+ }
509
+ },
510
+
511
+ /**
512
+ * Ruft Wetterdaten von Open-Meteo ab.
513
+ *
514
+ * @param {number} lat
515
+ * @param {number} lon
516
+ * @returns {Promise<any|null>}
517
+ */
518
+ async _fetchWeather(lat, lon) {
519
+ const url =
520
+ `https://api.open-meteo.com/v1/forecast` +
521
+ `?latitude=${encodeURIComponent(lat)}` +
522
+ `&longitude=${encodeURIComponent(lon)}` +
523
+ `&current=temperature_2m,wind_speed_10m` +
524
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode` +
525
+ `&timezone=auto`;
526
+
527
+ if (this._debugMode) {
528
+ this.adapter.log.debug(`[aiHelper] Rufe Wetterdaten ab: ${url}`);
529
+ }
530
+
531
+ return new Promise(resolve => {
532
+ try {
533
+ https
534
+ .get(url, res => {
535
+ let data = '';
536
+ res.on('data', chunk => {
537
+ data += chunk;
538
+ });
539
+ res.on('end', () => {
540
+ try {
541
+ if (!data) {
542
+ this.adapter.log.warn('[aiHelper] Wetterabfrage: leere Antwort erhalten');
543
+ return resolve(null);
544
+ }
545
+ const json = JSON.parse(data);
546
+ resolve(json);
547
+ } catch (err) {
548
+ this.adapter.log.warn(`[aiHelper] Fehler beim Parsen der Wetterdaten: ${err.message}`);
549
+ resolve(null);
550
+ }
551
+ });
552
+ })
553
+ .on('error', err => {
554
+ this.adapter.log.warn(`[aiHelper] Fehler bei Wetterabfrage: ${err.message}`);
555
+ resolve(null);
556
+ });
557
+ } catch (err) {
558
+ this.adapter.log.warn(`[aiHelper] Unerwarteter Fehler bei Wetterabfrage: ${err.message}`);
559
+ resolve(null);
560
+ }
561
+ });
562
+ },
563
+
564
+ // ---------------------------------------------------------------------
565
+ // Textgeneratoren
566
+ // ---------------------------------------------------------------------
567
+
568
+ /**
569
+ * Erzeugt einen gut lesbaren Wetterhinweis-Text.
570
+ *
571
+ * @param {any} weather
572
+ * @returns {string}
573
+ */
574
+ _buildWeatherAdviceText(weather) {
575
+ try {
576
+ const tmax = this._safeArrayValue(weather?.daily?.temperature_2m_max, 0);
577
+ const tmin = this._safeArrayValue(weather?.daily?.temperature_2m_min, 0);
578
+ const code = this._safeArrayValue(weather?.daily?.weathercode, 0);
579
+ const desc = this._describeWeatherCode(code);
580
+
581
+ let text = 'Wetterhinweis für heute: ';
582
+
583
+ if (tmax != null && tmin != null) {
584
+ text += `zwischen ${tmin.toFixed(1)} °C und ${tmax.toFixed(1)} °C, `;
585
+ } else if (tmax != null) {
586
+ text += `bis maximal ${tmax.toFixed(1)} °C, `;
587
+ }
588
+
589
+ text += desc ? `${desc}.` : 'genaue Wetterlage konnte nicht bestimmt werden.';
590
+
591
+ return text;
592
+ } catch {
593
+ return 'Wetterhinweis: Die aktuellen Wetterdaten konnten nicht ausgewertet werden.';
594
+ }
595
+ },
596
+
597
+ /**
598
+ * Erzeugt die Tageszusammenfassung.
599
+ *
600
+ * @param {{weather:any,seasonActive:boolean,pumpOn:boolean,pumpMode:string,surfaceTemp:number|null}} ctx
601
+ * @returns {string}
602
+ */
603
+ _buildDailySummaryText(ctx) {
604
+ const { weather, seasonActive, pumpOn, pumpMode, surfaceTemp } = ctx || {};
605
+
606
+ let parts = [];
607
+
608
+ // Saisonstatus
609
+ if (seasonActive) {
610
+ parts.push('Die Poolsaison ist aktuell AKTIV.');
611
+ } else {
612
+ parts.push('Die Poolsaison ist aktuell NICHT aktiv.');
613
+ }
614
+
615
+ // Pumpenstatus
616
+ if (pumpOn) {
617
+ parts.push(`Die Pumpe ist derzeit EIN (Modus: ${pumpMode || 'unbekannt'}).`);
618
+ } else {
619
+ parts.push(`Die Pumpe ist derzeit AUS (Modus: ${pumpMode || 'unbekannt'}).`);
620
+ }
621
+
622
+ // Temperatur
623
+ if (surfaceTemp != null && !Number.isNaN(surfaceTemp)) {
624
+ parts.push(`Die gemessene Wassertemperatur an der Oberfläche beträgt etwa ${surfaceTemp.toFixed(1)} °C.`);
625
+ }
626
+
627
+ // Wetterteil
628
+ if (weather) {
629
+ const tmax = this._safeArrayValue(weather?.daily?.temperature_2m_max, 0);
630
+ const tmin = this._safeArrayValue(weather?.daily?.temperature_2m_min, 0);
631
+ const code = this._safeArrayValue(weather?.daily?.weathercode, 0);
632
+ const desc = this._describeWeatherCode(code);
633
+
634
+ let w = 'Für heute sind ';
635
+ if (tmax != null && tmin != null) {
636
+ w += `Temperaturen zwischen ${tmin.toFixed(1)} °C und ${tmax.toFixed(1)} °C vorhergesagt`;
637
+ } else if (tmax != null) {
638
+ w += `Temperaturen bis etwa ${tmax.toFixed(1)} °C vorhergesagt`;
639
+ } else {
640
+ w += 'keine genauen Temperaturdaten verfügbar';
641
+ }
642
+
643
+ if (desc) {
644
+ w += `, bei einer Wetterlage: ${desc}.`;
645
+ } else {
646
+ w += '.';
647
+ }
648
+
649
+ parts.push(w);
650
+ } else {
651
+ parts.push('Aktuelle Wetterdaten stehen derzeit nicht zur Verfügung.');
652
+ }
653
+
654
+ return parts.join(' ');
655
+ },
656
+
657
+ /**
658
+ * Erzeugt tägliche Pool-Tipps abhängig von Wetter & Saison.
659
+ *
660
+ * @param {any} weather
661
+ * @param {boolean} seasonActive
662
+ * @returns {string}
663
+ */
664
+ _buildPoolTipsText(weather, seasonActive) {
665
+ if (!seasonActive) {
666
+ return 'Poolsaison ist aktuell nicht aktiv. Es sind keine speziellen Pool-Tipps notwendig.';
667
+ }
668
+
669
+ const tmax = this._safeArrayValue(weather?.daily?.temperature_2m_max, 0);
670
+ const code = this._safeArrayValue(weather?.daily?.weathercode, 0);
671
+ const desc = this._describeWeatherCode(code);
672
+
673
+ if (tmax == null) {
674
+ return 'Pool-Tipp: Es liegen keine Temperaturdaten vor. Bitte Poolbetrieb nach eigenem Gefühl planen.';
675
+ }
676
+
677
+ let text = 'Pool-Tipp für heute: ';
678
+
679
+ //--------------------------------------------------------
680
+ // NEU: Erweiterte Analyse für Pool-Tipps
681
+ //--------------------------------------------------------
682
+
683
+ // 1) Wind / Sturm
684
+ const wind = weather?.current?.wind_speed_10m ?? null;
685
+ if (wind != null) {
686
+ if (wind >= 60) {
687
+ text +=
688
+ '⚠️ Extrem starker Sturm erwartet! Bitte unbedingt die Abdeckung sichern und alle Gegenstände im Poolbereich fest verankern. ';
689
+ } else if (wind >= 45) {
690
+ text += 'Achtung: Starke Windböen treten auf. Bitte Abdeckung fixieren und lose Gegenstände sichern. ';
691
+ } else if (wind >= 30) {
692
+ text += 'Es wird windig – Abdeckung gut verschließen und empfindliches Zubehör schützen. ';
693
+ }
694
+ }
695
+
696
+ // 2) Regen / Starkregen
697
+ if (code != null) {
698
+ if ([65, 82].includes(code)) {
699
+ text += 'Kräftige Regenschauer erwartet – Abdeckung geschlossen halten. ';
700
+ } else if ([61, 63, 80, 81].includes(code)) {
701
+ text += 'Es wird regnerisch – Abdeckung eher geschlossen lassen. ';
702
+ }
703
+ }
704
+
705
+ // 3) Gewitter / Hagel
706
+ if (code === 95) {
707
+ text += '⚡ Gewitterwarnung! Bitte Solarfolie sichern und Technik vor Feuchtigkeit schützen. ';
708
+ }
709
+ if (code === 96 || code === 99) {
710
+ text += '⚠️ Hagelgefahr! Bitte empfindliche Geräte schützen und Poolbereich räumen. ';
711
+ }
712
+
713
+ // 4) Temperatur / Hitze
714
+ if (tmax >= 28) {
715
+ text += 'Sehr warmes Badewetter – Abdeckung tagsüber offen lassen. Chlorverbrauch steigt. ';
716
+ } else if (tmax >= 22) {
717
+ text += 'Angenehme Temperaturen – normaler Poolbetrieb empfohlen. ';
718
+ } else if (tmax <= 16) {
719
+ text += 'Kühle Temperaturen – Abdeckung geschlossen halten, um Wärmeverluste zu reduzieren. ';
720
+ }
721
+
722
+ // Fallback falls noch nichts geschrieben wurde
723
+ if (text.trim() === 'Pool-Tipp für heute:') {
724
+ text += desc ? `${desc}. ` : 'Keine besonderen Hinweise für den heutigen Tag. ';
725
+ }
726
+
727
+ if (tmax >= 26) {
728
+ text += 'Es wird warm bis sehr warm – gutes Badewetter. ';
729
+ text +=
730
+ 'Die Pumpe kann tagsüber etwas länger laufen, und die Abdeckung sollte bei Sonnenschein geöffnet werden. ';
731
+ } else if (tmax >= 20) {
732
+ text += 'Es wird mild bis angenehm. ';
733
+ text += 'Eine normale Umwälzzeit reicht meist aus. Abdeckung nur bei Bedarf schließen. ';
734
+ } else {
735
+ text += 'Es bleibt eher kühl. ';
736
+ text +=
737
+ 'Die Umwälzzeit kann auf das Minimum reduziert werden, und eine Abdeckung hilft, Wärmeverluste zu vermeiden. ';
738
+ }
739
+
740
+ if (desc && /regen|schauer|gewitter|sturm/i.test(desc)) {
741
+ text +=
742
+ 'Achtung: Es ist mit Regen oder stärkerem Wind zu rechnen – Abdeckung bereit halten und Zubehör sichern.';
743
+ } else if (desc && /sonnig|klar/i.test(desc)) {
744
+ text += 'Bei sonniger Witterung steigt der Chlorverbrauch – Wasserwerte im Auge behalten.';
745
+ }
746
+
747
+ //--------------------------------------------------------
748
+ // NEU: Anti-Spam-Logik
749
+ //--------------------------------------------------------
750
+
751
+ // // Wettercode vergleichen
752
+ // if (code != null) {
753
+ // if (this._lastPoolTipCode === code) {
754
+ // // gleiches Wetter wie vorher → eventuell abbrechen
755
+ // const nowTs = Date.now();
756
+ // // nur jede 3 Stunden dieselbe Warnung erneut ausgeben
757
+ // if (nowTs - this._lastPoolTipTimestamp < 3 * 60 * 60 * 1000) {
758
+ // return 'Pool-Tipp: Keine neuen Hinweise – Bedingungen unverändert.';
759
+ // }
760
+ // }
761
+ // }
762
+
763
+ // Windlevel kategorisieren: 0 = ruhig, 1 = windig, 2 = stark, 3 = Sturm
764
+ let windLevel = 0;
765
+ if (wind != null) {
766
+ if (wind >= 60) {
767
+ windLevel = 3;
768
+ } else if (wind >= 45) {
769
+ windLevel = 2;
770
+ } else if (wind >= 30) {
771
+ windLevel = 1;
772
+ }
773
+ }
774
+
775
+ // // prüfen, ob derselbe Windlevel schon gemeldet wurde
776
+ // if (windLevel === this._lastPoolTipWindLevel) {
777
+ // const nowTs = Date.now();
778
+ // if (nowTs - this._lastPoolTipTimestamp < 3 * 60 * 60 * 1000) {
779
+ // return 'Pool-Tipp: Keine neuen Informationen – Wetter gleich geblieben.';
780
+ // }
781
+ // }
782
+
783
+ // Wenn wir hier sind → neuer Hinweis → Werte speichern
784
+ this._lastPoolTipCode = code;
785
+ this._lastPoolTipWindLevel = windLevel;
786
+ this._lastPoolTipTimestamp = Date.now();
787
+
788
+ return text;
789
+ },
790
+
791
+ /**
792
+ * Erzeugt Wochenend-Zusammenfassung (Samstag/Sonntag).
793
+ *
794
+ * @param {any} weather
795
+ * @param {boolean} seasonActive
796
+ * @param {number} weekday JS-Tag (0=So..6=Sa)
797
+ * @returns {string}
798
+ */
799
+ _buildWeekendSummaryText(weather, seasonActive, weekday) {
800
+ if (!weather) {
801
+ return 'Wochenendübersicht: Es stehen keine Wetterdaten zur Verfügung.';
802
+ }
803
+
804
+ const tmaxArr = weather?.daily?.temperature_2m_max || [];
805
+ const tminArr = weather?.daily?.temperature_2m_min || [];
806
+ const codeArr = weather?.daily?.weathercode || [];
807
+
808
+ // Indizes für Samstag/Sonntag bestimmen
809
+ let idxSat = null;
810
+ let idxSun = null;
811
+
812
+ if (weekday === 5) {
813
+ // Freitag → morgen Samstag, übermorgen Sonntag
814
+ idxSat = 1;
815
+ idxSun = 2;
816
+ } else if (weekday === 6) {
817
+ // Samstag → heute Samstag, morgen Sonntag
818
+ idxSat = 0;
819
+ idxSun = 1;
820
+ } else {
821
+ // Fallback: nächste zwei Tage
822
+ idxSat = 1;
823
+ idxSun = 2;
824
+ }
825
+
826
+ const satMax = this._safeArrayValue(tmaxArr, idxSat);
827
+ const satMin = this._safeArrayValue(tminArr, idxSat);
828
+ const satCode = this._safeArrayValue(codeArr, idxSat);
829
+ const satDesc = this._describeWeatherCode(satCode);
830
+
831
+ const sunMax = this._safeArrayValue(tmaxArr, idxSun);
832
+ const sunMin = this._safeArrayValue(tminArr, idxSun);
833
+ const sunCode = this._safeArrayValue(codeArr, idxSun);
834
+ const sunDesc = this._describeWeatherCode(sunCode);
835
+
836
+ let text = 'Wochenendübersicht: ';
837
+
838
+ text += 'Samstag: ';
839
+ if (satMax != null && satMin != null) {
840
+ text += `zwischen ${satMin.toFixed(1)} °C und ${satMax.toFixed(1)} °C`;
841
+ } else if (satMax != null) {
842
+ text += `bis etwa ${satMax.toFixed(1)} °C`;
843
+ } else {
844
+ text += 'keine Temperaturdaten';
845
+ }
846
+ if (satDesc) {
847
+ text += `, Wetter: ${satDesc}. `;
848
+ } else {
849
+ text += '. ';
850
+ }
851
+
852
+ text += 'Sonntag: ';
853
+ if (sunMax != null && sunMin != null) {
854
+ text += `zwischen ${sunMin.toFixed(1)} °C und ${sunMax.toFixed(1)} °C`;
855
+ } else if (sunMax != null) {
856
+ text += `bis etwa ${sunMax.toFixed(1)} °C`;
857
+ } else {
858
+ text += 'keine Temperaturdaten';
859
+ }
860
+ if (sunDesc) {
861
+ text += `, Wetter: ${sunDesc}. `;
862
+ } else {
863
+ text += '. ';
864
+ }
865
+
866
+ if (seasonActive) {
867
+ text += 'Für das Wochenende bietet sich je nach Temperaturentwicklung ein angepasster Poolbetrieb an.';
868
+ } else {
869
+ text +=
870
+ 'Die Poolsaison ist aktuell nicht aktiv – das Wochenende eignet sich eher zur Planung oder Wartung.';
871
+ }
872
+
873
+ return text;
874
+ },
875
+
876
+ /**
877
+ * Konvertiert Open-Meteo weathercode in eine deutsche Beschreibung.
878
+ *
879
+ * @param {number|null} code
880
+ * @returns {string}
881
+ */
882
+ _describeWeatherCode(code) {
883
+ if (code == null || Number.isNaN(code)) {
884
+ return '';
885
+ }
886
+
887
+ // Quelle: Open-Meteo Wettercodes (vereinfachte Gruppierung)
888
+ if (code === 0) {
889
+ return 'klarer, sonniger Himmel';
890
+ }
891
+ if (code === 1) {
892
+ return 'überwiegend sonnig mit wenigen Wolken';
893
+ }
894
+ if (code === 2) {
895
+ return 'wechselhaft bewölkt';
896
+ }
897
+ if (code === 3) {
898
+ return 'bedeckter Himmel';
899
+ }
900
+
901
+ if (code === 45 || code === 48) {
902
+ return 'Nebel oder Hochnebel';
903
+ }
904
+
905
+ if (code === 51 || code === 53 || code === 55) {
906
+ return 'leichter bis mäßiger Nieselregen';
907
+ }
908
+ if (code === 56 || code === 57) {
909
+ return 'gefrierender Nieselregen';
910
+ }
911
+
912
+ if (code === 61 || code === 63 || code === 65) {
913
+ return 'leichter bis kräftiger Regen';
914
+ }
915
+ if (code === 66 || code === 67) {
916
+ return 'gefrierender Regen';
917
+ }
918
+
919
+ if (code === 71 || code === 73 || code === 75) {
920
+ return 'leichter bis starker Schneefall';
921
+ }
922
+ if (code === 77) {
923
+ return 'Schneekörner';
924
+ }
925
+
926
+ if (code === 80 || code === 81 || code === 82) {
927
+ return 'Regenschauer';
928
+ }
929
+ if (code === 85 || code === 86) {
930
+ return 'Schneeschauer';
931
+ }
932
+
933
+ if (code === 95) {
934
+ return 'Gewitter';
935
+ }
936
+ if (code === 96 || code === 99) {
937
+ return 'Gewitter mit Hagel';
938
+ }
939
+
940
+ return `Wettercode ${code}`;
941
+ },
942
+
943
+ // ---------------------------------------------------------------------
944
+ // State-/Hilfsfunktionen
945
+ // ---------------------------------------------------------------------
946
+
947
+ /**
948
+ * Schreibt einen AI-Output-Text.
949
+ *
950
+ * @param {string} id
951
+ * @param {string} text
952
+ */
953
+ async _writeOutput(id, text) {
954
+ if (!this.adapter) {
955
+ return;
956
+ }
957
+ if (!text) {
958
+ text = 'Keine Textausgabe verfügbar.';
959
+ }
960
+
961
+ try {
962
+ await this.adapter.setStateAsync(`ai.weather.outputs.${id}`, { val: text, ack: true });
963
+ await this.adapter.setStateAsync('ai.weather.outputs.last_message', { val: text, ack: true });
964
+
965
+ if (this._debugMode) {
966
+ this.adapter.log.debug(`[aiHelper] Output geschrieben → ai.weather.outputs.${id}: ${text}`);
967
+ }
968
+ } catch (err) {
969
+ this.adapter.log.error(`[aiHelper] Fehler beim Schreiben eines Outputs (${id}): ${err.message}`);
970
+ }
971
+ },
972
+
973
+ /**
974
+ * Optional: sendet Text an speech.queue, wenn erlaubt.
975
+ *
976
+ * @param {string} text
977
+ */
978
+ async _maybeSpeak(text) {
979
+ if (!this.adapter) {
980
+ return;
981
+ }
982
+ if (!text) {
983
+ return;
984
+ }
985
+
986
+ const allowSpeech = await this._getBool('ai.weather.switches.allow_speech', false);
987
+ if (!allowSpeech) {
988
+ if (this._debugMode) {
989
+ this.adapter.log.debug(
990
+ '[aiHelper] Sprachausgabe deaktiviert (ai.weather.switches.allow_speech = false)',
991
+ );
992
+ }
993
+ return;
994
+ }
995
+
996
+ try {
997
+ await this.adapter.setStateAsync('speech.queue', { val: text, ack: false });
998
+ this.adapter.log.info('[aiHelper] Text an speech.queue übergeben');
999
+ } catch (err) {
1000
+ this.adapter.log.warn(`[aiHelper] Fehler bei Sprachausgabe: ${err.message}`);
1001
+ }
1002
+ },
1003
+
1004
+ /**
1005
+ * Liest einen Bool-State.
1006
+ *
1007
+ * @param {string} id
1008
+ * @param {boolean} fallback
1009
+ * @returns {Promise<boolean>}
1010
+ */
1011
+ async _getBool(id, fallback) {
1012
+ try {
1013
+ const state = await this.adapter.getStateAsync(id);
1014
+ if (!state || state.val == null) {
1015
+ return fallback;
1016
+ }
1017
+ return !!state.val;
1018
+ } catch {
1019
+ return fallback;
1020
+ }
1021
+ },
1022
+
1023
+ /**
1024
+ * Liest einen String-State.
1025
+ *
1026
+ * @param {string} id
1027
+ * @param {string} fallback
1028
+ * @returns {Promise<string>}
1029
+ */
1030
+ async _getString(id, fallback) {
1031
+ try {
1032
+ const state = await this.adapter.getStateAsync(id);
1033
+ if (!state || state.val == null) {
1034
+ return fallback;
1035
+ }
1036
+ return String(state.val);
1037
+ } catch {
1038
+ return fallback;
1039
+ }
1040
+ },
1041
+
1042
+ /**
1043
+ * Liest einen Zahlen-State.
1044
+ *
1045
+ * @param {string} id
1046
+ * @param {number|null} fallback
1047
+ * @returns {Promise<number|null>}
1048
+ */
1049
+ async _getNumber(id, fallback) {
1050
+ try {
1051
+ const state = await this.adapter.getStateAsync(id);
1052
+ if (!state || state.val == null) {
1053
+ return fallback;
1054
+ }
1055
+ const num = Number(state.val);
1056
+ return Number.isNaN(num) ? fallback : num;
1057
+ } catch {
1058
+ return fallback;
1059
+ }
1060
+ },
1061
+
1062
+ /**
1063
+ * Liest Zeit-String HH:MM und liefert Objekt {hour,minute}.
1064
+ *
1065
+ * @param {string} id
1066
+ * @param {string} def
1067
+ * @returns {Promise<{hour:number,minute:number}>}
1068
+ */
1069
+ async _getTimeOrDefault(id, def) {
1070
+ const str = await this._getString(id, def);
1071
+
1072
+ // NEU: Log, welche Uhrzeit der Helper tatsächlich verwendet
1073
+ this.adapter.log.debug(`[aiHelper] Uhrzeit geladen: ${id} = "${str}" (Default: ${def})`);
1074
+
1075
+ const match = /^(\d{1,2}):(\d{2})$/.exec(str || '');
1076
+ let hour = 0;
1077
+ let minute = 0;
1078
+
1079
+ if (!match) {
1080
+ this.adapter.log.warn(`[aiHelper] Ungültiges Zeitformat in ${id}: "${str}" – verwende Default ${def}`);
1081
+ const defMatch = /^(\d{1,2}):(\d{2})$/.exec(def);
1082
+ if (defMatch) {
1083
+ hour = Number(defMatch[1]);
1084
+ minute = Number(defMatch[2]);
1085
+ }
1086
+ } else {
1087
+ hour = Math.min(Math.max(Number(match[1]), 0), 23);
1088
+ minute = Math.min(Math.max(Number(match[2]), 0), 59);
1089
+ }
1090
+
1091
+ return { hour, minute };
1092
+ },
1093
+
1094
+ /**
1095
+ * Sicherer Zugriff auf ein Array-Element.
1096
+ *
1097
+ * @param {Array<number>|undefined} arr
1098
+ * @param {number} idx
1099
+ * @returns {number|null}
1100
+ */
1101
+ _safeArrayValue(arr, idx) {
1102
+ if (!Array.isArray(arr)) {
1103
+ return null;
1104
+ }
1105
+ if (idx < 0 || idx >= arr.length) {
1106
+ return null;
1107
+ }
1108
+ const v = arr[idx];
1109
+ if (v == null || Number.isNaN(Number(v))) {
1110
+ return null;
1111
+ }
1112
+ return Number(v);
1113
+ },
1114
+ };
1115
+
1116
+ module.exports = aiHelper;