iobroker.poolcontrol 1.3.28 → 1.3.30

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.
Files changed (40) hide show
  1. package/README.md +22 -12
  2. package/admin/i18n/de/translations.json +4 -1
  3. package/admin/i18n/en/translations.json +8 -2
  4. package/admin/i18n/es/translations.json +4 -1
  5. package/admin/i18n/fr/translations.json +4 -1
  6. package/admin/i18n/it/translations.json +4 -1
  7. package/admin/i18n/nl/translations.json +4 -1
  8. package/admin/i18n/pl/translations.json +4 -1
  9. package/admin/i18n/pt/translations.json +4 -1
  10. package/admin/i18n/ru/translations.json +4 -1
  11. package/admin/i18n/uk/translations.json +4 -1
  12. package/admin/i18n/zh-cn/translations.json +4 -1
  13. package/admin/jsonConfig.json +31 -1
  14. package/io-package.json +27 -27
  15. package/lib/helpers/aiForecastHelper.js +6 -4
  16. package/lib/helpers/aiHelper.js +11 -9
  17. package/lib/helpers/consumptionHelper.js +47 -5
  18. package/lib/helpers/heatHelper.js +12 -9
  19. package/lib/helpers/infoHelper.js +11 -11
  20. package/lib/helpers/poolInsightsHelper.js +727 -0
  21. package/lib/helpers/pumpHelper.js +1 -4
  22. package/lib/helpers/statisticsHelper.js +7 -3
  23. package/lib/helpers/statisticsHelperMonth.js +19 -15
  24. package/lib/helpers/statisticsHelperWeek.js +11 -5
  25. package/lib/i18n/de.json +31 -1
  26. package/lib/i18n/en.json +34 -4
  27. package/lib/i18n/es.json +31 -1
  28. package/lib/i18n/fr.json +31 -1
  29. package/lib/i18n/it.json +31 -1
  30. package/lib/i18n/nl.json +31 -1
  31. package/lib/i18n/pl.json +31 -1
  32. package/lib/i18n/pt.json +31 -1
  33. package/lib/i18n/ru.json +31 -1
  34. package/lib/i18n/uk.json +31 -1
  35. package/lib/i18n/zh-cn.json +31 -1
  36. package/lib/stateDefinitions/poolInsightsStates.js +308 -0
  37. package/lib/stateDefinitions/solarStates.js +12 -3
  38. package/lib/stateDefinitions/statusStates.js +1 -1
  39. package/main.js +20 -0
  40. package/package.json +2 -2
@@ -0,0 +1,727 @@
1
+ 'use strict';
2
+
3
+ const { I18n } = require('@iobroker/adapter-core');
4
+
5
+ const POOL_INSIGHTS_PREFIX = 'analytics.insights.pool';
6
+ const DEFAULT_DAILY_ANALYSIS_TIME = '20:00';
7
+ const SPEECH_COOLDOWN_MS = 6 * 60 * 60 * 1000;
8
+
9
+ const poolInsightsHelper = {
10
+ adapter: null,
11
+ dailyTimer: null,
12
+ running: false,
13
+
14
+ init(adapter) {
15
+ this.adapter = adapter;
16
+
17
+ this.adapter.subscribeStates(`${POOL_INSIGHTS_PREFIX}.enabled`);
18
+ this.adapter.subscribeStates(`${POOL_INSIGHTS_PREFIX}.schedule_time`);
19
+ this.adapter.subscribeStates(`${POOL_INSIGHTS_PREFIX}.manual_trigger`);
20
+ this.adapter.subscribeStates(`${POOL_INSIGHTS_PREFIX}.send_to_speech_queue`);
21
+
22
+ void this._refreshSchedule();
23
+ this.adapter.log.debug('[poolInsightsHelper] Initialized');
24
+ },
25
+
26
+ handleStateChange(id, state) {
27
+ if (!state || state.ack === true) {
28
+ return;
29
+ }
30
+
31
+ if (id.endsWith(`${POOL_INSIGHTS_PREFIX}.manual_trigger`) && state.val === true) {
32
+ void this._handleManualTrigger();
33
+ return;
34
+ }
35
+
36
+ if (id.endsWith(`${POOL_INSIGHTS_PREFIX}.enabled`) || id.endsWith(`${POOL_INSIGHTS_PREFIX}.schedule_time`)) {
37
+ void this._refreshSchedule();
38
+ }
39
+ },
40
+
41
+ async _handleManualTrigger() {
42
+ try {
43
+ await this._runAnalysis('manual', true);
44
+ } finally {
45
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.manual_trigger`, false);
46
+ }
47
+ },
48
+
49
+ async _refreshSchedule() {
50
+ if (this.dailyTimer) {
51
+ this.adapter.clearTimeout(this.dailyTimer);
52
+ this.dailyTimer = null;
53
+ }
54
+
55
+ const enabled = await this._readBoolean(`${POOL_INSIGHTS_PREFIX}.enabled`);
56
+ if (!enabled) {
57
+ await this._writeDisabled('disabled');
58
+ return;
59
+ }
60
+
61
+ await this._scheduleDailyAnalysis();
62
+ },
63
+
64
+ async _scheduleDailyAnalysis() {
65
+ if (this.dailyTimer) {
66
+ this.adapter.clearTimeout(this.dailyTimer);
67
+ this.dailyTimer = null;
68
+ }
69
+
70
+ const scheduleTime = await this._readString(`${POOL_INSIGHTS_PREFIX}.schedule_time`);
71
+ const { hours, minutes } = this._parseScheduleTime(scheduleTime);
72
+ const now = new Date();
73
+ const next = new Date(now);
74
+ next.setHours(hours, minutes, 0, 0);
75
+ if (next <= now) {
76
+ next.setDate(next.getDate() + 1);
77
+ }
78
+
79
+ const delay = Math.max(1000, next.getTime() - now.getTime());
80
+ this.dailyTimer = this.adapter.setTimeout(async () => {
81
+ this.dailyTimer = null;
82
+ await this._runAnalysis('daily', true);
83
+ await this._refreshSchedule();
84
+ }, delay);
85
+
86
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.status`, 'scheduled');
87
+ this.adapter.log.debug(`[poolInsightsHelper] Daily analysis scheduled for ${next.toISOString()}`);
88
+ },
89
+
90
+ _parseScheduleTime(value) {
91
+ const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value);
92
+ if (match) {
93
+ return {
94
+ hours: Number(match[1]),
95
+ minutes: Number(match[2]),
96
+ };
97
+ }
98
+
99
+ this.adapter.log.debug(
100
+ `[poolInsightsHelper] Invalid schedule_time ${value}, using fallback ${DEFAULT_DAILY_ANALYSIS_TIME}`,
101
+ );
102
+ return {
103
+ hours: 20,
104
+ minutes: 0,
105
+ };
106
+ },
107
+
108
+ async _runAnalysis(reason, allowSpeech) {
109
+ if (this.running) {
110
+ this.adapter.log.debug('[poolInsightsHelper] Analysis already running - skipped');
111
+ return;
112
+ }
113
+
114
+ this.running = true;
115
+ try {
116
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.status`, 'running');
117
+
118
+ const snapshot = await this._readSnapshot();
119
+ const result = this._buildResult(snapshot, reason);
120
+
121
+ await this._writeResult(result);
122
+
123
+ if (allowSpeech) {
124
+ await this._sendSpeechIfAllowed(result, reason);
125
+ }
126
+
127
+ this.adapter.log.debug(`[poolInsightsHelper] Analysis completed (${reason})`);
128
+ } catch (err) {
129
+ await this._writeError(reason, err);
130
+ this.adapter.log.warn(`[poolInsightsHelper] Analysis failed: ${err.message}`);
131
+ } finally {
132
+ this.running = false;
133
+ }
134
+ },
135
+
136
+ async _readSnapshot() {
137
+ return {
138
+ temperature: {
139
+ surfaceCurrent: await this._readNumber('temperature.surface.current'),
140
+ surfaceMinToday: await this._readNumber('temperature.surface.min_today'),
141
+ surfaceMaxToday: await this._readNumber('temperature.surface.max_today'),
142
+ surfaceSummaryJson: await this._readString(
143
+ 'analytics.statistics.temperature.today.surface.summary_json',
144
+ ),
145
+ },
146
+ pump: {
147
+ runtimeTodaySeconds: await this._readNumber('runtime.today_seconds'),
148
+ startCountToday: await this._readNumber('runtime.start_count_today'),
149
+ circulationRequired: await this._readNumber('circulation.daily_required'),
150
+ circulationRemaining: await this._readNumber('circulation.daily_remaining'),
151
+ mode: await this._readString('pump.mode'),
152
+ status: await this._readString('pump.status'),
153
+ error: await this._readBoolean('pump.error'),
154
+ activeHelper: await this._readString('pump.active_helper'),
155
+ },
156
+ solar: {
157
+ ranToday: await this._readBoolean('analytics.insights.solar.results.solar_ran_today'),
158
+ estimatedGainTodayKwh: await this._readNumber(
159
+ 'analytics.insights.solar.results.estimated_gain_today_kwh',
160
+ ),
161
+ evaluationAvailable:
162
+ (await this._readString('analytics.insights.solar.results.summary_json')) !== '' ||
163
+ (await this._readString('analytics.insights.solar.results.summary_html')) !== '',
164
+ },
165
+ photovoltaic: {
166
+ activeToday: await this._readBoolean('analytics.insights.photovoltaic.results.active_today'),
167
+ startsToday: await this._readNumber('analytics.insights.photovoltaic.results.starts_today'),
168
+ runtimeTodayMin: await this._readNumber('analytics.insights.photovoltaic.results.runtime_today_min'),
169
+ evaluationAvailable:
170
+ (await this._readString('analytics.insights.photovoltaic.results.summary_json')) !== '' ||
171
+ (await this._readString('analytics.insights.photovoltaic.results.summary_text')) !== '',
172
+ },
173
+ consumption: {
174
+ dayKwh: await this._readNumber('consumption.day_kwh'),
175
+ dayEur: await this._readNumber('costs.day_eur'),
176
+ },
177
+ chemistry: {
178
+ phAvailable: await this._hasValue('chemistry.ph.outputs.summary_text'),
179
+ tdsAvailable: await this._hasValue('chemistry.tds.outputs.summary_text'),
180
+ orpAvailable: await this._hasValue('chemistry.orp.outputs.summary_text'),
181
+ },
182
+ };
183
+ },
184
+
185
+ _buildResult(snapshot, reason) {
186
+ const observations = [];
187
+ const recommendations = [];
188
+ let level = 'ok';
189
+
190
+ const tempDelta = this._getTemperatureDelta(snapshot.temperature);
191
+ if (tempDelta !== null) {
192
+ const rounded = this._round(tempDelta, 1);
193
+ observations.push({
194
+ area: 'temperature',
195
+ level: tempDelta >= 1 ? 'ok' : 'info',
196
+ text: this._translate('pool_insights_observation_temperature_delta', { delta: rounded }),
197
+ });
198
+
199
+ if (tempDelta <= 0.2) {
200
+ level = this._raiseLevel(level, 'info');
201
+ recommendations.push(
202
+ this._createRecommendation(
203
+ 'temperature',
204
+ 'info',
205
+ 'low_temperature_change',
206
+ 0.7,
207
+ this._translate('pool_insights_recommendation_temperature_low_change'),
208
+ ['temperature.surface.min_today', 'temperature.surface.max_today'],
209
+ {
210
+ temperature_delta_c: rounded,
211
+ },
212
+ ),
213
+ );
214
+ }
215
+ }
216
+
217
+ if (snapshot.pump.runtimeTodaySeconds !== null) {
218
+ const runtimeLevel = snapshot.pump.runtimeTodaySeconds > 0 ? 'ok' : 'info';
219
+ level = this._raiseLevel(level, runtimeLevel);
220
+ observations.push({
221
+ area: 'pump',
222
+ level: runtimeLevel,
223
+ text: this._translate('pool_insights_observation_pump_runtime', {
224
+ runtime: this._formatRuntime(snapshot.pump.runtimeTodaySeconds),
225
+ }),
226
+ });
227
+ }
228
+
229
+ if (snapshot.pump.startCountToday !== null) {
230
+ observations.push({
231
+ area: 'pump',
232
+ level: snapshot.pump.startCountToday > 12 ? 'info' : 'ok',
233
+ text: this._translate('pool_insights_observation_pump_starts', {
234
+ count: snapshot.pump.startCountToday,
235
+ }),
236
+ });
237
+
238
+ if (snapshot.pump.startCountToday > 12) {
239
+ level = this._raiseLevel(level, 'info');
240
+ recommendations.push(
241
+ this._createRecommendation(
242
+ 'pump',
243
+ 'info',
244
+ 'many_pump_starts',
245
+ 0.8,
246
+ this._translate('pool_insights_recommendation_many_pump_starts'),
247
+ ['runtime.start_count_today'],
248
+ {
249
+ starts_today: snapshot.pump.startCountToday,
250
+ },
251
+ ),
252
+ );
253
+ }
254
+ }
255
+
256
+ if (snapshot.pump.error === true) {
257
+ level = 'warning';
258
+ observations.push({
259
+ area: 'pump',
260
+ level: 'warning',
261
+ text: this._translate('pool_insights_observation_pump_error'),
262
+ });
263
+ recommendations.push(
264
+ this._createRecommendation(
265
+ 'pump',
266
+ 'warning',
267
+ 'pump_error_active',
268
+ 1,
269
+ this._translate('pool_insights_recommendation_pump_error'),
270
+ ['pump.error'],
271
+ {
272
+ pump_error: true,
273
+ },
274
+ ),
275
+ );
276
+ }
277
+
278
+ this._appendSolarObservations(snapshot.solar, observations);
279
+ this._appendPhotovoltaicObservations(snapshot.photovoltaic, observations);
280
+
281
+ if (snapshot.photovoltaic.startsToday !== null && snapshot.photovoltaic.startsToday > 10) {
282
+ level = this._raiseLevel(level, 'info');
283
+ recommendations.push(
284
+ this._createRecommendation(
285
+ 'photovoltaic',
286
+ 'info',
287
+ 'many_pv_starts',
288
+ 0.8,
289
+ this._translate('pool_insights_recommendation_many_pv_starts'),
290
+ ['analytics.insights.photovoltaic.results.starts_today'],
291
+ {
292
+ pv_starts_today: snapshot.photovoltaic.startsToday,
293
+ },
294
+ ),
295
+ );
296
+ }
297
+
298
+ this._appendConsumptionObservations(snapshot.consumption, observations);
299
+ this._appendChemistryObservations(snapshot.chemistry, observations);
300
+
301
+ if (this._hasLimitedCoreData(snapshot)) {
302
+ level = this._raiseLevel(level, 'info');
303
+ observations.push({
304
+ area: 'pool',
305
+ level: 'info',
306
+ text: this._translate('pool_insights_observation_not_enough_data'),
307
+ });
308
+ }
309
+
310
+ if (observations.length === 0) {
311
+ observations.push({
312
+ area: 'pool',
313
+ level: 'ok',
314
+ text: this._translate('pool_insights_observation_not_enough_data'),
315
+ });
316
+ level = 'info';
317
+ }
318
+
319
+ level = observations.reduce((current, entry) => this._raiseLevel(current, entry.level), level);
320
+
321
+ const summaryText = this._buildSummaryText(level, observations, recommendations);
322
+ const now = new Date().toISOString();
323
+
324
+ return {
325
+ status: 'completed',
326
+ level,
327
+ summaryText,
328
+ summaryHtml: this._buildSummaryHtml(level, observations, recommendations),
329
+ summaryJson: {
330
+ status: 'completed',
331
+ level,
332
+ reason,
333
+ last_update: now,
334
+ inputs: snapshot,
335
+ observations,
336
+ recommendations,
337
+ },
338
+ observations,
339
+ recommendations,
340
+ lastUpdate: now,
341
+ reason,
342
+ };
343
+ },
344
+
345
+ _getTemperatureDelta(temperature) {
346
+ if (temperature.surfaceMaxToday === null || temperature.surfaceMinToday === null) {
347
+ return null;
348
+ }
349
+
350
+ return Math.max(0, temperature.surfaceMaxToday - temperature.surfaceMinToday);
351
+ },
352
+
353
+ _appendSolarObservations(solar, observations) {
354
+ if (solar.ranToday === true) {
355
+ observations.push({
356
+ area: 'solar',
357
+ level: 'ok',
358
+ text: this._translate('pool_insights_observation_solar_ran_today'),
359
+ });
360
+ } else if (solar.evaluationAvailable) {
361
+ observations.push({
362
+ area: 'solar',
363
+ level: 'info',
364
+ text: this._translate('pool_insights_observation_solar_not_ran_today'),
365
+ });
366
+ }
367
+
368
+ if (solar.estimatedGainTodayKwh !== null && solar.estimatedGainTodayKwh > 0) {
369
+ observations.push({
370
+ area: 'solar',
371
+ level: 'ok',
372
+ text: this._translate('pool_insights_observation_solar_gain_today', {
373
+ kwh: this._round(solar.estimatedGainTodayKwh, 2),
374
+ }),
375
+ });
376
+ } else if (solar.evaluationAvailable && solar.ranToday !== true) {
377
+ observations.push({
378
+ area: 'solar',
379
+ level: 'info',
380
+ text: this._translate('pool_insights_observation_solar_evaluation_available'),
381
+ });
382
+ }
383
+ },
384
+
385
+ _appendPhotovoltaicObservations(photovoltaic, observations) {
386
+ if (photovoltaic.activeToday === true) {
387
+ observations.push({
388
+ area: 'photovoltaic',
389
+ level: 'ok',
390
+ text: this._translate('pool_insights_observation_pv_used_today'),
391
+ });
392
+ } else if (photovoltaic.evaluationAvailable) {
393
+ observations.push({
394
+ area: 'photovoltaic',
395
+ level: 'info',
396
+ text: this._translate('pool_insights_observation_pv_evaluation_available'),
397
+ });
398
+ }
399
+
400
+ if (photovoltaic.runtimeTodayMin !== null && photovoltaic.runtimeTodayMin > 0) {
401
+ observations.push({
402
+ area: 'photovoltaic',
403
+ level: 'ok',
404
+ text: this._translate('pool_insights_observation_pv_runtime_today', {
405
+ minutes: this._round(photovoltaic.runtimeTodayMin, 0),
406
+ }),
407
+ });
408
+ }
409
+ },
410
+
411
+ _appendConsumptionObservations(consumption, observations) {
412
+ if (consumption.dayKwh !== null) {
413
+ observations.push({
414
+ area: 'energy',
415
+ level: 'ok',
416
+ text: this._translate('pool_insights_observation_consumption_today', {
417
+ kwh: this._round(consumption.dayKwh, 2),
418
+ }),
419
+ });
420
+ }
421
+
422
+ if (consumption.dayEur !== null) {
423
+ observations.push({
424
+ area: 'energy',
425
+ level: 'ok',
426
+ text: this._translate('pool_insights_observation_costs_today', {
427
+ eur: this._round(consumption.dayEur, 2),
428
+ }),
429
+ });
430
+ }
431
+ },
432
+
433
+ _appendChemistryObservations(chemistry, observations) {
434
+ const chemistryEvaluations = [
435
+ {
436
+ area: 'chemistry_ph',
437
+ available: chemistry.phAvailable,
438
+ key: 'pool_insights_observation_ph_evaluation_available',
439
+ },
440
+ {
441
+ area: 'chemistry_tds',
442
+ available: chemistry.tdsAvailable,
443
+ key: 'pool_insights_observation_tds_evaluation_available',
444
+ },
445
+ {
446
+ area: 'chemistry_orp',
447
+ available: chemistry.orpAvailable,
448
+ key: 'pool_insights_observation_orp_evaluation_available',
449
+ },
450
+ ];
451
+
452
+ for (const entry of chemistryEvaluations) {
453
+ if (!entry.available) {
454
+ continue;
455
+ }
456
+
457
+ observations.push({
458
+ area: entry.area,
459
+ level: 'info',
460
+ text: this._translate(entry.key),
461
+ });
462
+ }
463
+ },
464
+
465
+ _hasLimitedCoreData(snapshot) {
466
+ return (
467
+ this._getTemperatureDelta(snapshot.temperature) === null ||
468
+ snapshot.pump.runtimeTodaySeconds === null ||
469
+ snapshot.pump.startCountToday === null ||
470
+ snapshot.consumption.dayKwh === null
471
+ );
472
+ },
473
+
474
+ _buildSummaryText(level, observations, recommendations) {
475
+ const lead = {
476
+ ok: this._translate('pool_insights_summary_ok'),
477
+ info: this._translate('pool_insights_summary_info'),
478
+ warning: this._translate('pool_insights_summary_warning'),
479
+ }[level];
480
+
481
+ const parts = [lead || this._translate('pool_insights_summary_completed')];
482
+ for (const entry of observations.slice(0, 4)) {
483
+ parts.push(entry.text);
484
+ }
485
+ for (const entry of recommendations.slice(0, 2)) {
486
+ parts.push(entry.text);
487
+ }
488
+
489
+ return parts.join(' ');
490
+ },
491
+
492
+ _buildSummaryHtml(level, observations, recommendations) {
493
+ const items = observations.map(entry => `<li>${this._escapeHtml(entry.text)}</li>`).join('');
494
+ const recommendationItems = recommendations.map(entry => `<li>${this._escapeHtml(entry.text)}</li>`).join('');
495
+
496
+ return [
497
+ `<div class="pool-insights pool-insights-${this._escapeHtml(level)}">`,
498
+ `<strong>${this._escapeHtml(level)}</strong>`,
499
+ items ? `<ul>${items}</ul>` : '',
500
+ recommendationItems
501
+ ? `<p>${this._translate('pool_insights_label_recommendations')}:</p><ul>${recommendationItems}</ul>`
502
+ : '',
503
+ '</div>',
504
+ ].join('');
505
+ },
506
+
507
+ async _writeResult(result) {
508
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.status`, result.status);
509
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.level`, result.level);
510
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.summary_text`, result.summaryText);
511
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.summary_html`, result.summaryHtml);
512
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.summary_json`, JSON.stringify(result.summaryJson));
513
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.observations_json`, JSON.stringify(result.observations));
514
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.recommendations_json`, JSON.stringify(result.recommendations));
515
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.last_update`, result.lastUpdate);
516
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.debug.last_reason`, result.reason);
517
+ },
518
+
519
+ async _writeDisabled(reason) {
520
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.status`, 'disabled');
521
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.level`, 'none');
522
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.summary_text`, '');
523
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.summary_html`, '');
524
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.summary_json`, '{}');
525
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.observations_json`, '[]');
526
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.recommendations_json`, '[]');
527
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.last_update`, new Date().toISOString());
528
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.debug.last_reason`, reason);
529
+ },
530
+
531
+ async _writeError(reason, err) {
532
+ const now = new Date().toISOString();
533
+ const text = this._translate('pool_insights_error_analysis_failed');
534
+
535
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.status`, 'error');
536
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.level`, 'warning');
537
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.summary_text`, text);
538
+ await this._setState(
539
+ `${POOL_INSIGHTS_PREFIX}.summary_html`,
540
+ `<div class="pool-insights-error">${this._escapeHtml(text)}</div>`,
541
+ );
542
+ await this._setState(
543
+ `${POOL_INSIGHTS_PREFIX}.summary_json`,
544
+ JSON.stringify({
545
+ status: 'error',
546
+ level: 'warning',
547
+ reason,
548
+ last_update: now,
549
+ error: err?.message || 'unknown',
550
+ }),
551
+ );
552
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.observations_json`, '[]');
553
+ await this._setState(
554
+ `${POOL_INSIGHTS_PREFIX}.recommendations_json`,
555
+ JSON.stringify([
556
+ {
557
+ area: 'pool',
558
+ level: 'warning',
559
+ text: this._translate('pool_insights_error_check_log'),
560
+ },
561
+ ]),
562
+ );
563
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.last_update`, now);
564
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.debug.last_reason`, reason);
565
+ },
566
+
567
+ async _sendSpeechIfAllowed(result, reason) {
568
+ const sendToSpeech = await this._readBoolean(`${POOL_INSIGHTS_PREFIX}.send_to_speech_queue`);
569
+ if (!sendToSpeech || !result.summaryText) {
570
+ return;
571
+ }
572
+
573
+ if (reason === 'daily') {
574
+ const lastSpeechAt = await this._readString(`${POOL_INSIGHTS_PREFIX}.last_speech_at`);
575
+ const lastSpeechTime = Date.parse(lastSpeechAt || '');
576
+ if (Number.isFinite(lastSpeechTime) && Date.now() - lastSpeechTime < SPEECH_COOLDOWN_MS) {
577
+ this.adapter.log.debug('[poolInsightsHelper] Automatic speech skipped due to cooldown');
578
+ return;
579
+ }
580
+ }
581
+
582
+ // Use the relative state id intentionally so multiple adapter instances write to their own speech.queue.
583
+ await this._setState('speech.queue', result.summaryText, false);
584
+ await this._setState(`${POOL_INSIGHTS_PREFIX}.last_speech_at`, new Date().toISOString());
585
+ },
586
+
587
+ async _readState(id) {
588
+ try {
589
+ return await this.adapter.getStateAsync(id);
590
+ } catch {
591
+ return null;
592
+ }
593
+ },
594
+
595
+ async _readString(id) {
596
+ const state = await this._readState(id);
597
+ if (state === null || state.val === null || state.val === undefined || state.val === '') {
598
+ return '';
599
+ }
600
+ return String(state.val);
601
+ },
602
+
603
+ async _readNumber(id) {
604
+ const state = await this._readState(id);
605
+ if (state === null || state.val === null || state.val === undefined || state.val === '') {
606
+ return null;
607
+ }
608
+
609
+ const value = Number(state.val);
610
+ return Number.isFinite(value) ? value : null;
611
+ },
612
+
613
+ async _readBoolean(id) {
614
+ const state = await this._readState(id);
615
+ return state?.val === true;
616
+ },
617
+
618
+ async _hasValue(id) {
619
+ return (await this._readString(id)) !== '';
620
+ },
621
+
622
+ async _setState(id, val, ack = true) {
623
+ await this.adapter.setStateChangedAsync(id, { val, ack });
624
+ },
625
+
626
+ _createRecommendation(area, level, reason, confidence, text, sourceStates = [], evidence = {}) {
627
+ return {
628
+ area: String(area || ''),
629
+ level: String(level || ''),
630
+ reason: String(reason || ''),
631
+ confidence: this._clampConfidence(confidence),
632
+ text: String(text || ''),
633
+ source_states: Array.isArray(sourceStates)
634
+ ? sourceStates.filter(value => value !== undefined).map(value => String(value))
635
+ : [],
636
+ evidence: this._cleanEvidence(evidence),
637
+ };
638
+ },
639
+
640
+ _clampConfidence(value) {
641
+ const confidence = Number(value);
642
+ if (!Number.isFinite(confidence)) {
643
+ return 0;
644
+ }
645
+ return Math.min(1, Math.max(0, confidence));
646
+ },
647
+
648
+ _cleanEvidence(evidence) {
649
+ if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) {
650
+ return {};
651
+ }
652
+
653
+ return this._cleanObject(evidence);
654
+ },
655
+
656
+ _cleanObject(object) {
657
+ const cleaned = {};
658
+ for (const [key, value] of Object.entries(object)) {
659
+ const cleanedValue = this._cleanValue(value);
660
+ if (cleanedValue !== undefined) {
661
+ cleaned[key] = cleanedValue;
662
+ }
663
+ }
664
+ return cleaned;
665
+ },
666
+
667
+ _cleanValue(value) {
668
+ if (value === undefined) {
669
+ return undefined;
670
+ }
671
+ if (Array.isArray(value)) {
672
+ return value.map(entry => this._cleanValue(entry)).filter(entry => entry !== undefined);
673
+ }
674
+ if (value !== null && typeof value === 'object') {
675
+ return this._cleanObject(value);
676
+ }
677
+ return value;
678
+ },
679
+
680
+ _formatRuntime(seconds) {
681
+ const totalMinutes = Math.max(0, Math.round(seconds / 60));
682
+ const hours = Math.floor(totalMinutes / 60);
683
+ const minutes = totalMinutes % 60;
684
+ if (hours > 0) {
685
+ return `${hours} h ${minutes} min`;
686
+ }
687
+ return `${minutes} min`;
688
+ },
689
+
690
+ _raiseLevel(current, candidate) {
691
+ const order = { ok: 0, info: 1, warning: 2 };
692
+ return order[candidate] > order[current] ? candidate : current;
693
+ },
694
+
695
+ _round(value, digits) {
696
+ const factor = 10 ** digits;
697
+ return Math.round(value * factor) / factor;
698
+ },
699
+
700
+ _translate(key, replacements = {}) {
701
+ let text = I18n.translate(key);
702
+ for (const [name, value] of Object.entries(replacements)) {
703
+ text = text.replace(`{${name}}`, String(value));
704
+ }
705
+ return text;
706
+ },
707
+
708
+ _escapeHtml(text) {
709
+ return String(text)
710
+ .replace(/&/g, '&amp;')
711
+ .replace(/</g, '&lt;')
712
+ .replace(/>/g, '&gt;')
713
+ .replace(/"/g, '&quot;')
714
+ .replace(/'/g, '&#39;');
715
+ },
716
+
717
+ cleanup() {
718
+ if (this.dailyTimer) {
719
+ this.adapter.clearTimeout(this.dailyTimer);
720
+ this.dailyTimer = null;
721
+ }
722
+ this.running = false;
723
+ this.adapter && this.adapter.log.debug('[poolInsightsHelper] Cleanup completed');
724
+ },
725
+ };
726
+
727
+ module.exports = poolInsightsHelper;