iobroker.poolcontrol 0.7.3 → 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.
- package/README.md +54 -3
- package/admin/jsonConfig.json +96 -2
- package/io-package.json +27 -27
- package/lib/helpers/aiForecastHelper.js +448 -0
- package/lib/helpers/aiHelper.js +255 -55
- package/lib/stateDefinitions/aiStates.js +91 -27
- package/lib/stateDefinitions/controlStates.js +10 -1
- package/main.js +13 -3
- package/package.json +1 -1
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* eslint-disable jsdoc/require-param-description */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* aiForecastHelper
|
|
6
|
+
* --------------------------------------------------------------
|
|
7
|
+
* Erzeugt die „Vorhersage für morgen“.
|
|
8
|
+
*
|
|
9
|
+
* Nutzt folgende States:
|
|
10
|
+
* ai.weather.switches.tomorrow_forecast_enabled
|
|
11
|
+
* ai.weather.switches.allow_speech
|
|
12
|
+
* ai.weather.switches.debug_mode
|
|
13
|
+
*
|
|
14
|
+
* ai.weather.schedule.tomorrow_forecast_time
|
|
15
|
+
*
|
|
16
|
+
* ai.weather.outputs.tomorrow_forecast
|
|
17
|
+
*
|
|
18
|
+
* WICHTIG:
|
|
19
|
+
* Die Vorhersage wird NICHT in ai.weather.outputs.last_message geschrieben,
|
|
20
|
+
* damit wichtige Warnmeldungen nicht überschrieben werden.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const https = require('https');
|
|
24
|
+
|
|
25
|
+
const aiForecastHelper = {
|
|
26
|
+
adapter: null,
|
|
27
|
+
timer: null,
|
|
28
|
+
_debugMode: false,
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Initialisiert den Forecast-Helper.
|
|
32
|
+
*
|
|
33
|
+
* @param {import('iobroker').Adapter} adapter
|
|
34
|
+
*/
|
|
35
|
+
async init(adapter) {
|
|
36
|
+
this.adapter = adapter;
|
|
37
|
+
this.adapter.log.info('[aiForecastHelper] Initialisierung gestartet');
|
|
38
|
+
|
|
39
|
+
await this._refreshTimer();
|
|
40
|
+
|
|
41
|
+
// ----------------------------------------------------------
|
|
42
|
+
// NEU: Sofortige Ausführung beim Adapterstart (wenn aktiviert)
|
|
43
|
+
// ----------------------------------------------------------
|
|
44
|
+
const enabled = await this._getBool('ai.weather.switches.tomorrow_forecast_enabled', false);
|
|
45
|
+
if (enabled) {
|
|
46
|
+
this.adapter.log.info('[aiForecastHelper] Starte einmalige Sofort-Vorhersage (Adapterstart)');
|
|
47
|
+
try {
|
|
48
|
+
await this._runForecast();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
this.adapter.log.warn(`[aiForecastHelper] Fehler bei Sofort-Vorhersage: ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.adapter.log.info('[aiForecastHelper] Initialisierung abgeschlossen');
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Aufräumen beim Adapter-Stop.
|
|
59
|
+
*/
|
|
60
|
+
cleanup() {
|
|
61
|
+
if (this.timer) {
|
|
62
|
+
clearInterval(this.timer);
|
|
63
|
+
this.timer = null;
|
|
64
|
+
}
|
|
65
|
+
this.adapter && this.adapter.log.debug('[aiForecastHelper] Cleanup abgeschlossen');
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reagiert auf State-Änderungen an switches + schedule.
|
|
70
|
+
*
|
|
71
|
+
* @param id
|
|
72
|
+
* @param state
|
|
73
|
+
*/
|
|
74
|
+
async handleStateChange(id, state) {
|
|
75
|
+
if (!state || !this.adapter) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Von Adapter selbst gesetzt? → ignorieren
|
|
80
|
+
if (state.from && state.from.startsWith(`system.adapter.${this.adapter.name}.`)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Nur Ai-Weather-spezifische Änderungen beachten
|
|
85
|
+
if (!id.includes('ai.weather.switches.') && !id.includes('ai.weather.schedule.')) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ----------------------------------------------------------
|
|
90
|
+
// NEU: Sofortige Ausführung, wenn der Forecast aktiviert wird
|
|
91
|
+
// ----------------------------------------------------------
|
|
92
|
+
if (id.endsWith('ai.weather.switches.tomorrow_forecast_enabled') && state.val === true) {
|
|
93
|
+
this.adapter.log.info('[aiForecastHelper] Forecast aktiviert → einmalige sofortige Ausführung');
|
|
94
|
+
try {
|
|
95
|
+
await this._runForecast();
|
|
96
|
+
} catch (err) {
|
|
97
|
+
this.adapter.log.warn(`[aiForecastHelper] Fehler bei Sofort-Ausführung: ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.adapter.log.info(`[aiForecastHelper] Änderung erkannt: ${id} = ${state.val}`);
|
|
102
|
+
await this._refreshTimer();
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------
|
|
106
|
+
// TIMER-VERWALTUNG
|
|
107
|
+
// ---------------------------------------------------------------------
|
|
108
|
+
async _refreshTimer() {
|
|
109
|
+
// alten Timer stoppen
|
|
110
|
+
if (this.timer) {
|
|
111
|
+
clearInterval(this.timer);
|
|
112
|
+
this.timer = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const enabled = await this._getBool('ai.weather.switches.tomorrow_forecast_enabled', false);
|
|
116
|
+
this._debugMode = await this._getBool('ai.weather.switches.debug_mode', false);
|
|
117
|
+
|
|
118
|
+
if (!enabled) {
|
|
119
|
+
this.adapter.log.info('[aiForecastHelper] Forecast deaktiviert – kein Timer aktiv');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const time = await this._getTimeOrDefault('ai.weather.schedule.tomorrow_forecast_time', '19:00');
|
|
124
|
+
|
|
125
|
+
this.adapter.log.info(
|
|
126
|
+
`[aiForecastHelper] Forecast-Timer gesetzt für ${time.hour}:${String(time.minute).padStart(2, '0')}`,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// minütlicher Check
|
|
130
|
+
this.timer = setInterval(async () => {
|
|
131
|
+
const now = new Date();
|
|
132
|
+
if (now.getHours() === time.hour && now.getMinutes() === time.minute) {
|
|
133
|
+
try {
|
|
134
|
+
await this._runForecast();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
this.adapter.log.warn(`[aiForecastHelper] Fehler im Timer: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}, 60 * 1000);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------
|
|
143
|
+
// HAUPTFUNKTION – VORHERSAGE ERZEUGEN
|
|
144
|
+
// ---------------------------------------------------------------------
|
|
145
|
+
async _runForecast() {
|
|
146
|
+
try {
|
|
147
|
+
this.adapter.log.info('[aiForecastHelper] Erzeuge Vorhersage für morgen ...');
|
|
148
|
+
|
|
149
|
+
const geo = await this._loadGeoLocation();
|
|
150
|
+
if (!geo) {
|
|
151
|
+
this.adapter.log.warn('[aiForecastHelper] Abbruch – keine Geodaten verfügbar');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const weather = await this._fetchWeather(geo.lat, geo.lon);
|
|
156
|
+
if (!weather) {
|
|
157
|
+
this.adapter.log.warn('[aiForecastHelper] Abbruch – keine Wetterdaten');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const text = this._buildForecastText(weather);
|
|
162
|
+
|
|
163
|
+
await this._writeOutput('tomorrow_forecast', text);
|
|
164
|
+
await this._maybeSpeak(text);
|
|
165
|
+
|
|
166
|
+
this.adapter.log.info('[aiForecastHelper] Vorhersage für morgen erzeugt');
|
|
167
|
+
} catch (err) {
|
|
168
|
+
this.adapter.log.warn(`[aiForecastHelper] Fehler bei _runForecast(): ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------
|
|
173
|
+
// TEXTGENERATOR – Vorhersage für morgen (erweitert)
|
|
174
|
+
// ---------------------------------------------------------------------
|
|
175
|
+
_buildForecastText(weather) {
|
|
176
|
+
try {
|
|
177
|
+
const tmaxArr = weather?.daily?.temperature_2m_max || [];
|
|
178
|
+
const tminArr = weather?.daily?.temperature_2m_min || [];
|
|
179
|
+
const codeArr = weather?.daily?.weathercode || [];
|
|
180
|
+
|
|
181
|
+
// NEU: zusätzliche Arrays für Regen & Wind
|
|
182
|
+
const rainProbArr = weather?.daily?.precipitation_probability_max || []; // NEU
|
|
183
|
+
const windMaxArr = weather?.daily?.wind_speed_10m_max || []; // NEU
|
|
184
|
+
|
|
185
|
+
// Index 1 = morgen
|
|
186
|
+
const tmax = this._safeValue(tmaxArr[1]);
|
|
187
|
+
const tmin = this._safeValue(tminArr[1]);
|
|
188
|
+
const code = this._safeValue(codeArr[1]);
|
|
189
|
+
|
|
190
|
+
// NEU: Zusatzwerte für Regen & Wind
|
|
191
|
+
const rain = this._safeValue(rainProbArr[1]); // Regenwahrscheinlichkeit in %
|
|
192
|
+
const wind = this._safeValue(windMaxArr[1]); // max. Wind (km/h, laut Open-Meteo)
|
|
193
|
+
|
|
194
|
+
const desc = this._describeWeatherCode(code);
|
|
195
|
+
|
|
196
|
+
let text = 'Vorhersage für morgen: ';
|
|
197
|
+
|
|
198
|
+
// --- Temperaturteil (wie bisher, nur leicht ergänzt) ---
|
|
199
|
+
if (tmax != null && tmin != null) {
|
|
200
|
+
text += `Temperaturen zwischen ${tmin.toFixed(1)} °C und ${tmax.toFixed(1)} °C`;
|
|
201
|
+
} else if (tmax != null) {
|
|
202
|
+
text += `Temperaturen bis etwa ${tmax.toFixed(1)} °C`;
|
|
203
|
+
} else {
|
|
204
|
+
text += 'keine Temperaturdaten verfügbar';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (desc) {
|
|
208
|
+
text += `, Wetter: ${desc}.`;
|
|
209
|
+
} else {
|
|
210
|
+
text += '.';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --------------------------------------------------------
|
|
214
|
+
// NEU: Regenwahrscheinlichkeit
|
|
215
|
+
// --------------------------------------------------------
|
|
216
|
+
if (rain != null) {
|
|
217
|
+
text += ` Regenwahrscheinlichkeit: ${rain}%`;
|
|
218
|
+
if (rain >= 70) {
|
|
219
|
+
text += ' – hoher Regenanteil erwartet.';
|
|
220
|
+
} else if (rain >= 40) {
|
|
221
|
+
text += ' – zeitweise Schauer möglich.';
|
|
222
|
+
} else {
|
|
223
|
+
text += ' – überwiegend trocken.';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
text += ' ';
|
|
228
|
+
|
|
229
|
+
// --------------------------------------------------------
|
|
230
|
+
// NEU: Windanalyse (stark / frisch / leicht)
|
|
231
|
+
// --------------------------------------------------------
|
|
232
|
+
if (wind != null) {
|
|
233
|
+
let windText = '';
|
|
234
|
+
|
|
235
|
+
if (wind >= 60) {
|
|
236
|
+
windText = 'sehr starker Wind / Sturm';
|
|
237
|
+
} else if (wind >= 40) {
|
|
238
|
+
windText = 'starker Wind';
|
|
239
|
+
} else if (wind >= 25) {
|
|
240
|
+
windText = 'frischer Wind';
|
|
241
|
+
} else if (wind >= 10) {
|
|
242
|
+
windText = 'leichter Wind';
|
|
243
|
+
} else {
|
|
244
|
+
windText = 'kaum spürbarer Wind';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
text += `Wind: ${windText} (max. ${wind} km/h). `;
|
|
248
|
+
|
|
249
|
+
// NEU: Warnung bei starkem Wind
|
|
250
|
+
if (wind >= 40) {
|
|
251
|
+
text += '⚠️ Achtung: starker Wind vorhergesagt – Abdeckung und loses Zubehör sichern. ';
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --------------------------------------------------------
|
|
256
|
+
// NEU: Einschätzung „Solarwetter“
|
|
257
|
+
// --------------------------------------------------------
|
|
258
|
+
if (tmax != null) {
|
|
259
|
+
if (tmax >= 26) {
|
|
260
|
+
text +=
|
|
261
|
+
'Morgen ist gutes Solarwetter – der Pool kann sich deutlich erwärmen, Abdeckung tagsüber geöffnet lassen. ';
|
|
262
|
+
} else if (tmax >= 20) {
|
|
263
|
+
text +=
|
|
264
|
+
'Morgen ist moderates Solarwetter – leichte Erwärmung möglich, Abdeckung je nach Bedarf öffnen. ';
|
|
265
|
+
} else {
|
|
266
|
+
text +=
|
|
267
|
+
'Nur wenig Solarwärme zu erwarten – Abdeckung möglichst geschlossen halten, um Wärmeverluste zu reduzieren. ';
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// --------------------------------------------------------
|
|
272
|
+
// NEU: Pool-Empfehlungen für morgen
|
|
273
|
+
// --------------------------------------------------------
|
|
274
|
+
text += 'Pool-Empfehlungen für morgen: ';
|
|
275
|
+
|
|
276
|
+
if (rain != null && rain >= 60) {
|
|
277
|
+
text += 'Abdeckung geschlossen halten, da mit Regen zu rechnen ist. ';
|
|
278
|
+
} else if (wind != null && wind >= 40) {
|
|
279
|
+
text += 'Abdeckung gut sichern und empfindliche Gegenstände aus dem Poolbereich entfernen. ';
|
|
280
|
+
} else if (tmax != null && tmax >= 25) {
|
|
281
|
+
text += 'Gutes Badewetter – Pumpe tagsüber ausreichend laufen lassen und Abdeckung geöffnet halten. ';
|
|
282
|
+
} else {
|
|
283
|
+
text += 'Normale Poolnutzung möglich, Einstellungen können unverändert bleiben. ';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return text.trim();
|
|
287
|
+
} catch {
|
|
288
|
+
return 'Vorhersage: Wetterdaten konnten nicht ausgewertet werden.';
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------------
|
|
293
|
+
// WETTER & GEO
|
|
294
|
+
// ---------------------------------------------------------------------
|
|
295
|
+
async _loadGeoLocation() {
|
|
296
|
+
try {
|
|
297
|
+
const obj = await this.adapter.getForeignObjectAsync('system.config');
|
|
298
|
+
if (!obj || !obj.common) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const lat = Number(obj.common.latitude);
|
|
303
|
+
const lon = Number(obj.common.longitude);
|
|
304
|
+
|
|
305
|
+
if (Number.isNaN(lat) || Number.isNaN(lon)) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (this._debugMode) {
|
|
310
|
+
this.adapter.log.debug(`[aiForecastHelper] Geodaten: lat=${lat}, lon=${lon}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return { lat, lon };
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
async _fetchWeather(lat, lon) {
|
|
320
|
+
const url =
|
|
321
|
+
`https://api.open-meteo.com/v1/forecast` +
|
|
322
|
+
`?latitude=${lat}&longitude=${lon}` +
|
|
323
|
+
`¤t=temperature_2m,wind_speed_10m` +
|
|
324
|
+
// NEU: zusätzliche Daily-Parameter für Regenwahrscheinlichkeit & max. Wind
|
|
325
|
+
`&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_probability_max,wind_speed_10m_max` +
|
|
326
|
+
`&timezone=auto`;
|
|
327
|
+
|
|
328
|
+
if (this._debugMode) {
|
|
329
|
+
this.adapter.log.debug(`[aiForecastHelper] Abruf: ${url}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return new Promise(resolve => {
|
|
333
|
+
try {
|
|
334
|
+
https
|
|
335
|
+
.get(url, res => {
|
|
336
|
+
let data = '';
|
|
337
|
+
res.on('data', chunk => (data += chunk));
|
|
338
|
+
res.on('end', () => {
|
|
339
|
+
try {
|
|
340
|
+
resolve(JSON.parse(data));
|
|
341
|
+
} catch {
|
|
342
|
+
resolve(null);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
})
|
|
346
|
+
.on('error', () => resolve(null));
|
|
347
|
+
} catch {
|
|
348
|
+
resolve(null);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------
|
|
354
|
+
// AUSGABE
|
|
355
|
+
// ---------------------------------------------------------------------
|
|
356
|
+
async _writeOutput(id, text) {
|
|
357
|
+
try {
|
|
358
|
+
await this.adapter.setStateAsync(`ai.weather.outputs.${id}`, { val: text, ack: true });
|
|
359
|
+
|
|
360
|
+
if (this._debugMode) {
|
|
361
|
+
this.adapter.log.debug(`[aiForecastHelper] Output geschrieben: ai.weather.outputs.${id}`);
|
|
362
|
+
}
|
|
363
|
+
} catch (err) {
|
|
364
|
+
this.adapter.log.error(`[aiForecastHelper] Fehler beim Schreiben eines Outputs (${id}): ${err.message}`);
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
async _maybeSpeak(text) {
|
|
369
|
+
const allowed = await this._getBool('ai.weather.switches.allow_speech', false);
|
|
370
|
+
if (!allowed) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
await this.adapter.setStateAsync('speech.queue', { val: text, ack: false });
|
|
376
|
+
this.adapter.log.info('[aiForecastHelper] Sprachausgabe gestartet');
|
|
377
|
+
} catch (err) {
|
|
378
|
+
this.adapter.log.warn(`[aiForecastHelper] Fehler bei Sprachausgabe: ${err.message}`);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------
|
|
383
|
+
// HILFSFUNKTIONEN
|
|
384
|
+
// ---------------------------------------------------------------------
|
|
385
|
+
_safeValue(v) {
|
|
386
|
+
if (v == null) {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
const num = Number(v);
|
|
390
|
+
return Number.isNaN(num) ? null : num;
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
_describeWeatherCode(code) {
|
|
394
|
+
if (code == null) {
|
|
395
|
+
return '';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const map = {
|
|
399
|
+
0: 'klarer Himmel',
|
|
400
|
+
1: 'überwiegend sonnig',
|
|
401
|
+
2: 'wechselhaft bewölkt',
|
|
402
|
+
3: 'bedeckt',
|
|
403
|
+
45: 'Nebel',
|
|
404
|
+
48: 'Hochnebel',
|
|
405
|
+
51: 'leichter Nieselregen',
|
|
406
|
+
53: 'mäßiger Nieselregen',
|
|
407
|
+
55: 'starker Nieselregen',
|
|
408
|
+
61: 'leichter Regen',
|
|
409
|
+
63: 'mäßiger Regen',
|
|
410
|
+
65: 'starker Regen',
|
|
411
|
+
80: 'Regenschauer',
|
|
412
|
+
81: 'kräftige Schauer',
|
|
413
|
+
82: 'Starkschauer',
|
|
414
|
+
95: 'Gewitter',
|
|
415
|
+
96: 'Gewitter mit Hagel',
|
|
416
|
+
99: 'starkes Gewitter mit Hagel',
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
return map[code] || `Wettercode ${code}`;
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
async _getBool(id, fallback) {
|
|
423
|
+
try {
|
|
424
|
+
const st = await this.adapter.getStateAsync(id);
|
|
425
|
+
return st && st.val != null ? !!st.val : fallback;
|
|
426
|
+
} catch {
|
|
427
|
+
return fallback;
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
async _getTimeOrDefault(id, def) {
|
|
432
|
+
const state = await this.adapter.getStateAsync(id);
|
|
433
|
+
const str = state?.val ?? def;
|
|
434
|
+
|
|
435
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(String(str));
|
|
436
|
+
if (!m) {
|
|
437
|
+
const defM = /^(\d{1,2}):(\d{2})$/.exec(def);
|
|
438
|
+
return { hour: Number(defM[1]), minute: Number(defM[2]) };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
hour: Math.min(Math.max(Number(m[1]), 0), 23),
|
|
443
|
+
minute: Math.min(Math.max(Number(m[2]), 0), 59),
|
|
444
|
+
};
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
module.exports = aiForecastHelper;
|