iobroker.poolcontrol 0.7.1 → 0.7.3

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