iobroker.poolcontrol 1.3.10 → 1.3.11

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,830 @@
1
+ 'use strict';
2
+
3
+ const { I18n } = require('@iobroker/adapter-core');
4
+
5
+ const MAX_HISTORY_AGE_MS = 30 * 24 * 60 * 60 * 1000;
6
+ const MIN_SAMPLE_INTERVAL_MS = 15 * 60 * 1000;
7
+
8
+ const chemistryTdsHelper = {
9
+ adapter: null,
10
+ sourceStateId: '',
11
+ evalTimer: null,
12
+ pumpStartTs: 0,
13
+
14
+ init(adapter) {
15
+ this.adapter = adapter;
16
+
17
+ void this._subscribeStates();
18
+ void this._loadSourceState();
19
+ this._scheduleEvaluation('init', 500);
20
+
21
+ this.adapter.log.debug('[chemistryTdsHelper] Initialized');
22
+ },
23
+
24
+ async _subscribeStates() {
25
+ const ids = [
26
+ 'chemistry.tds.enabled',
27
+ 'chemistry.tds.input.source_mode',
28
+ 'chemistry.tds.input.source_state_id',
29
+ 'chemistry.tds.input.manual_value',
30
+ 'chemistry.tds.measurement.location',
31
+ 'chemistry.tds.measurement.flow_required',
32
+ 'chemistry.tds.measurement.stabilization_time_sec',
33
+ 'chemistry.tds.reference.initial_value',
34
+ 'chemistry.tds.reference.reset_initial_reference',
35
+ 'pump.pump_switch',
36
+ 'status.season_active',
37
+ ];
38
+
39
+ for (const id of ids) {
40
+ await this.adapter.subscribeStatesAsync(id);
41
+ }
42
+
43
+ this.adapter.log.debug('[chemistryTdsHelper] Own states subscribed');
44
+ },
45
+
46
+ async _loadSourceState() {
47
+ const stateId = await this._readString('chemistry.tds.input.source_state_id');
48
+
49
+ if (!stateId) {
50
+ this.sourceStateId = '';
51
+ return;
52
+ }
53
+
54
+ this.sourceStateId = stateId;
55
+
56
+ try {
57
+ this.adapter.subscribeForeignStates(stateId);
58
+ this.adapter.log.debug(`[chemistryTdsHelper] Subscribed foreign TDS source: ${stateId}`);
59
+ } catch (err) {
60
+ this.adapter.log.warn(
61
+ `[chemistryTdsHelper] Could not subscribe foreign TDS source "${stateId}": ${err.message}`,
62
+ );
63
+ }
64
+ },
65
+
66
+ async handleStateChange(id, state) {
67
+ if (!state) {
68
+ return;
69
+ }
70
+
71
+ try {
72
+ if (id.endsWith('chemistry.tds.input.source_state_id') && state.ack === false) {
73
+ await this._handleSourceStateChanged(String(state.val || ''));
74
+ return;
75
+ }
76
+
77
+ if (
78
+ id.endsWith('chemistry.tds.reference.reset_initial_reference') &&
79
+ state.ack === false &&
80
+ state.val === true
81
+ ) {
82
+ await this._setCurrentAsInitialReference();
83
+ await this.adapter.setStateChangedAsync('chemistry.tds.reference.reset_initial_reference', {
84
+ val: false,
85
+ ack: true,
86
+ });
87
+ return;
88
+ }
89
+
90
+ if (this.sourceStateId && id === this.sourceStateId) {
91
+ await this._handleIncomingValue('state', state.val, 'external_state');
92
+ return;
93
+ }
94
+
95
+ if (id.endsWith('chemistry.tds.input.manual_value') && state.ack === false) {
96
+ await this._handleIncomingValue('manual', state.val, 'manual_value');
97
+ return;
98
+ }
99
+
100
+ if (this._isRelevantOwnState(id)) {
101
+ this._scheduleEvaluation(`state_change:${id}`, 500);
102
+ }
103
+ } catch (err) {
104
+ this.adapter.log.warn(`[chemistryTdsHelper] Error in handleStateChange: ${err.message}`);
105
+ }
106
+ },
107
+
108
+ _isRelevantOwnState(id) {
109
+ const relevant = [
110
+ 'chemistry.tds.enabled',
111
+ 'chemistry.tds.input.source_mode',
112
+ 'chemistry.tds.measurement.location',
113
+ 'chemistry.tds.measurement.flow_required',
114
+ 'chemistry.tds.measurement.stabilization_time_sec',
115
+ 'chemistry.tds.reference.initial_value',
116
+ 'pump.pump_switch',
117
+ 'status.season_active',
118
+ ];
119
+
120
+ return relevant.some(stateId => id.endsWith(stateId));
121
+ },
122
+
123
+ async _handleSourceStateChanged(newStateId) {
124
+ if (this.sourceStateId && this.sourceStateId !== newStateId) {
125
+ try {
126
+ this.adapter.unsubscribeForeignStates(this.sourceStateId);
127
+ } catch (err) {
128
+ this.adapter.log.debug(`[chemistryTdsHelper] Could not unsubscribe old TDS source: ${err.message}`);
129
+ }
130
+ }
131
+
132
+ this.sourceStateId = newStateId;
133
+
134
+ if (!newStateId) {
135
+ await this._setBool('chemistry.tds.input.source_valid', false);
136
+ await this._setString(
137
+ 'chemistry.tds.input.source_status',
138
+ I18n.translate('No TDS source state configured.'),
139
+ );
140
+ this._scheduleEvaluation('source_state_removed', 500);
141
+ return;
142
+ }
143
+
144
+ try {
145
+ this.adapter.subscribeForeignStates(newStateId);
146
+ await this._setBool('chemistry.tds.input.source_valid', true);
147
+ await this._setString('chemistry.tds.input.source_status', I18n.translate('TDS source state configured.'));
148
+ } catch (err) {
149
+ await this._setBool('chemistry.tds.input.source_valid', false);
150
+ await this._setString(
151
+ 'chemistry.tds.input.source_status',
152
+ I18n.translate('TDS source state could not be subscribed.'),
153
+ );
154
+ this.adapter.log.warn(
155
+ `[chemistryTdsHelper] Could not subscribe new TDS source "${newStateId}": ${err.message}`,
156
+ );
157
+ }
158
+
159
+ this._scheduleEvaluation('source_state_changed', 500);
160
+ },
161
+
162
+ async _handleIncomingValue(source, rawValue, reason) {
163
+ const mode = await this._readString('chemistry.tds.input.source_mode');
164
+
165
+ if (source === 'manual' && mode !== 'manual') {
166
+ return;
167
+ }
168
+
169
+ if (source === 'state' && mode !== 'state') {
170
+ return;
171
+ }
172
+
173
+ await this._processValue(source, rawValue, reason, true);
174
+ },
175
+
176
+ _scheduleEvaluation(reason, delayMs = 500) {
177
+ if (this.evalTimer) {
178
+ this.adapter.clearTimeout(this.evalTimer);
179
+ this.evalTimer = null;
180
+ }
181
+
182
+ this.evalTimer = this.adapter.setTimeout(() => {
183
+ this.evalTimer = null;
184
+ void this._evaluate(reason);
185
+ }, delayMs);
186
+ },
187
+
188
+ async _evaluate(reason = '') {
189
+ const enabled = await this._readBoolean('chemistry.tds.enabled');
190
+
191
+ if (!enabled) {
192
+ await this._writeDisabled(reason || 'disabled');
193
+ return;
194
+ }
195
+
196
+ const mode = await this._readString('chemistry.tds.input.source_mode');
197
+
198
+ if (mode === 'disabled') {
199
+ await this._setBool('chemistry.tds.input.source_valid', false);
200
+ await this._setBool('chemistry.tds.input.value_valid', false);
201
+ await this._setString('chemistry.tds.input.source_status', I18n.translate('TDS input is disabled.'));
202
+ await this._writeDisabled(reason || 'source_disabled');
203
+ return;
204
+ }
205
+
206
+ if (mode === 'manual') {
207
+ const manualValue = await this._readNumber('chemistry.tds.input.manual_value');
208
+ await this._processValue('manual', manualValue, reason || 'manual_evaluation', false);
209
+ return;
210
+ }
211
+
212
+ if (mode === 'state') {
213
+ const sourceStateId = await this._readString('chemistry.tds.input.source_state_id');
214
+
215
+ if (!sourceStateId) {
216
+ await this._writeInvalid(I18n.translate('No valid TDS source is configured.'), 'missing_source_state');
217
+ return;
218
+ }
219
+
220
+ try {
221
+ const sourceState = await this.adapter.getForeignStateAsync(sourceStateId);
222
+
223
+ if (!sourceState) {
224
+ await this._writeInvalid(
225
+ I18n.translate('The configured TDS source state does not exist.'),
226
+ 'source_not_found',
227
+ );
228
+ return;
229
+ }
230
+
231
+ await this._processValue('state', sourceState.val, reason || 'state_evaluation', false);
232
+ } catch (err) {
233
+ await this._writeInvalid(
234
+ I18n.translate('The configured TDS source could not be read.'),
235
+ `source_read_error: ${err.message}`,
236
+ );
237
+ }
238
+
239
+ return;
240
+ }
241
+
242
+ await this._writeInvalid(I18n.translate('Unknown TDS source mode.'), 'unknown_source_mode');
243
+ },
244
+
245
+ async _processValue(source, rawValue, reason, forceSample) {
246
+ const now = new Date();
247
+ const value = Number(rawValue);
248
+
249
+ await this._setString('chemistry.tds.input.last_value_at', this._formatDateTime(now));
250
+
251
+ if (source === 'manual') {
252
+ await this._setBool('chemistry.tds.input.source_valid', true);
253
+ await this._setString('chemistry.tds.input.source_status', I18n.translate('Manual TDS value is used.'));
254
+ } else {
255
+ await this._setBool('chemistry.tds.input.source_valid', true);
256
+ await this._setString('chemistry.tds.input.source_status', I18n.translate('External TDS source is valid.'));
257
+ }
258
+
259
+ const valueValid = Number.isFinite(value) && value >= 0 && value <= 10000;
260
+
261
+ await this._setNumber('chemistry.tds.input.current_value', Number.isFinite(value) ? value : 0);
262
+ await this._setBool('chemistry.tds.input.value_valid', valueValid);
263
+
264
+ if (!valueValid) {
265
+ await this._writeInvalid(
266
+ I18n.translate('The TDS value is invalid. Please check the measurement or sensor.'),
267
+ reason || 'invalid_value',
268
+ );
269
+ return;
270
+ }
271
+
272
+ const measurementAllowed = await this._checkMeasurementAllowed(now);
273
+
274
+ if (!measurementAllowed.allowed) {
275
+ await this._setBool('chemistry.tds.measurement.allowed', false);
276
+ await this._setString('chemistry.tds.measurement.ignored_reason', measurementAllowed.reason);
277
+ await this._setString('chemistry.tds.evaluation.overall_status', measurementAllowed.status);
278
+ await this._setString('chemistry.tds.evaluation.recommendation', measurementAllowed.recommendation);
279
+ await this._setBool('chemistry.tds.evaluation.action_required', false);
280
+ await this._setString('chemistry.tds.debug.last_reason', reason || measurementAllowed.reason);
281
+ await this._setString('chemistry.tds.debug.last_update', this._formatDateTime(now));
282
+ return;
283
+ }
284
+
285
+ await this._setBool('chemistry.tds.measurement.allowed', true);
286
+ await this._setString('chemistry.tds.measurement.ignored_reason', '');
287
+
288
+ await this._updateLastValues(value, now);
289
+
290
+ const history = await this._updateHistory(value, now, forceSample);
291
+ const reference = await this._updateReference(value, now);
292
+ const trend = await this._calculateTrend(value, now, history);
293
+ const evaluation = await this._evaluateTds(value, reference, trend);
294
+
295
+ await this._writeTrend(trend);
296
+ await this._writeEvaluation(evaluation);
297
+ await this._writeOutputs(value, reference, trend, evaluation);
298
+
299
+ await this._setString('chemistry.tds.debug.last_reason', reason || 'value_processed');
300
+ await this._setString('chemistry.tds.debug.last_update', this._formatDateTime(now));
301
+ },
302
+
303
+ async _checkMeasurementAllowed(now) {
304
+ const location = await this._readString('chemistry.tds.measurement.location');
305
+ const flowRequired = await this._readBoolean('chemistry.tds.measurement.flow_required');
306
+ const pumpRunning = await this._readBoolean('pump.pump_switch');
307
+
308
+ await this._setBool('chemistry.tds.measurement.pump_running', pumpRunning);
309
+
310
+ const needsFlow = flowRequired || location === 'measurement_cell' || location === 'pipe_section';
311
+
312
+ if (!needsFlow || location === 'pool' || location === 'manual') {
313
+ await this._setBool('chemistry.tds.measurement.stabilized', true);
314
+ return { allowed: true, reason: '', status: 'ok', recommendation: '' };
315
+ }
316
+
317
+ if (!pumpRunning) {
318
+ this.pumpStartTs = 0;
319
+ await this._setBool('chemistry.tds.measurement.stabilized', false);
320
+
321
+ return {
322
+ allowed: false,
323
+ reason: 'pump_off',
324
+ status: 'waiting_for_pump',
325
+ recommendation: I18n.translate(
326
+ 'TDS evaluation is waiting for the pool pump because the sensor is in a measurement section.',
327
+ ),
328
+ };
329
+ }
330
+
331
+ if (!this.pumpStartTs) {
332
+ this.pumpStartTs = now.getTime();
333
+ }
334
+
335
+ const stabilizationSec = Math.max(
336
+ 0,
337
+ await this._readNumber('chemistry.tds.measurement.stabilization_time_sec'),
338
+ );
339
+ const elapsedSec = Math.floor((now.getTime() - this.pumpStartTs) / 1000);
340
+ const stabilized = elapsedSec >= stabilizationSec;
341
+
342
+ await this._setBool('chemistry.tds.measurement.stabilized', stabilized);
343
+
344
+ if (!stabilized) {
345
+ return {
346
+ allowed: false,
347
+ reason: 'stabilization_pending',
348
+ status: 'waiting_for_stabilization',
349
+ recommendation: I18n.translate(
350
+ 'TDS evaluation is waiting until the measurement section has stabilized after pump start.',
351
+ ),
352
+ };
353
+ }
354
+
355
+ return { allowed: true, reason: '', status: 'ok', recommendation: '' };
356
+ },
357
+
358
+ async _updateLastValues(value, now) {
359
+ const lastValid = await this._readNumberOrNull('chemistry.tds.input.last_valid_value');
360
+ const lastValidAt = await this._readString('chemistry.tds.input.last_valid_value_at');
361
+
362
+ if (lastValid !== null && lastValidAt) {
363
+ await this._setNumber('chemistry.tds.input.previous_value', lastValid);
364
+ await this._setString('chemistry.tds.input.previous_value_at', lastValidAt);
365
+
366
+ const previousDate = this._parseGermanDateTime(lastValidAt);
367
+
368
+ if (previousDate) {
369
+ const minutes = Math.max(0, Math.round((now.getTime() - previousDate.getTime()) / 60000));
370
+ await this._setNumber('chemistry.tds.input.minutes_since_previous_value', minutes);
371
+ }
372
+ }
373
+
374
+ await this._setNumber('chemistry.tds.input.last_valid_value', value);
375
+ await this._setString('chemistry.tds.input.last_valid_value_at', this._formatDateTime(now));
376
+ },
377
+
378
+ async _updateHistory(value, now, forceSample) {
379
+ let samples = await this._readJsonArray('chemistry.tds.history.samples_json');
380
+ const nowTs = now.getTime();
381
+ const minTs = nowTs - MAX_HISTORY_AGE_MS;
382
+
383
+ samples = samples.filter(
384
+ sample => sample && Number(sample.ts) >= minTs && Number.isFinite(Number(sample.value)),
385
+ );
386
+
387
+ const newest = samples.length ? samples[samples.length - 1] : null;
388
+ const newestTs = newest ? Number(newest.ts) : 0;
389
+ const shouldStore = forceSample || !newest || nowTs - newestTs >= MIN_SAMPLE_INTERVAL_MS;
390
+
391
+ if (shouldStore) {
392
+ samples.push({
393
+ ts: nowTs,
394
+ time: this._formatDateTime(now),
395
+ value,
396
+ });
397
+ }
398
+
399
+ samples = samples.filter(sample => sample && Number(sample.ts) >= minTs);
400
+
401
+ await this._setString('chemistry.tds.history.samples_json', JSON.stringify(samples));
402
+ await this._setNumber('chemistry.tds.history.samples_count', samples.length);
403
+
404
+ if (samples.length) {
405
+ await this._setString(
406
+ 'chemistry.tds.history.oldest_sample_at',
407
+ samples[0].time || this._formatDateTime(new Date(samples[0].ts)),
408
+ );
409
+ await this._setString(
410
+ 'chemistry.tds.history.newest_sample_at',
411
+ samples[samples.length - 1].time || this._formatDateTime(new Date(samples[samples.length - 1].ts)),
412
+ );
413
+ }
414
+
415
+ return samples;
416
+ },
417
+
418
+ async _updateReference(value, now) {
419
+ let initialValue = await this._readNumber('chemistry.tds.reference.initial_value');
420
+ let initialSet = await this._readBoolean('chemistry.tds.reference.initial_set');
421
+
422
+ if (!initialSet && initialValue <= 0) {
423
+ initialValue = value;
424
+ initialSet = true;
425
+
426
+ await this._setNumber('chemistry.tds.reference.initial_value', initialValue);
427
+ await this._setString('chemistry.tds.reference.initial_value_at', this._formatDateTime(now));
428
+ await this._setBool('chemistry.tds.reference.initial_set', true);
429
+ }
430
+
431
+ const delta = initialSet ? value - initialValue : 0;
432
+
433
+ await this._setNumber('chemistry.tds.reference.delta_since_initial', delta);
434
+
435
+ return {
436
+ initialValue,
437
+ initialSet,
438
+ delta,
439
+ };
440
+ },
441
+
442
+ async _setCurrentAsInitialReference() {
443
+ const currentValue = await this._readNumber('chemistry.tds.input.last_valid_value');
444
+
445
+ if (!Number.isFinite(currentValue) || currentValue <= 0) {
446
+ await this._setString('chemistry.tds.debug.last_reason', 'reference_reset_failed_no_valid_value');
447
+ return;
448
+ }
449
+
450
+ const now = new Date();
451
+
452
+ await this._setNumber('chemistry.tds.reference.initial_value', currentValue);
453
+ await this._setString('chemistry.tds.reference.initial_value_at', this._formatDateTime(now));
454
+ await this._setBool('chemistry.tds.reference.initial_set', true);
455
+ await this._setNumber('chemistry.tds.reference.delta_since_initial', 0);
456
+ await this._setString('chemistry.tds.debug.last_reason', 'reference_reset');
457
+ await this._setString('chemistry.tds.debug.last_update', this._formatDateTime(now));
458
+
459
+ this._scheduleEvaluation('reference_reset', 500);
460
+ },
461
+
462
+ async _calculateTrend(currentValue, now, samples) {
463
+ const nowTs = now.getTime();
464
+
465
+ const ref24h = this._findReferenceSample(samples, nowTs - 24 * 60 * 60 * 1000);
466
+ const ref7d = this._findReferenceSample(samples, nowTs - 7 * 24 * 60 * 60 * 1000);
467
+ const ref30d = this._findReferenceSample(samples, nowTs - 30 * 24 * 60 * 60 * 1000);
468
+
469
+ const delta24h = ref24h ? currentValue - ref24h.value : 0;
470
+ const delta7d = ref7d ? currentValue - ref7d.value : 0;
471
+ const delta30d = ref30d ? currentValue - ref30d.value : 0;
472
+
473
+ const direction = this._getOverallDirection(ref24h, ref7d, ref30d, delta24h, delta7d, delta30d);
474
+ const status = this._getTrendStatus(delta24h, delta7d, delta30d, ref24h, ref7d, ref30d);
475
+
476
+ return {
477
+ ref24h,
478
+ ref7d,
479
+ ref30d,
480
+ delta24h,
481
+ delta7d,
482
+ delta30d,
483
+ direction,
484
+ status,
485
+ };
486
+ },
487
+
488
+ _findReferenceSample(samples, targetTs) {
489
+ if (!samples.length) {
490
+ return null;
491
+ }
492
+
493
+ let best = null;
494
+ let bestDistance = Number.MAX_SAFE_INTEGER;
495
+
496
+ for (const sample of samples) {
497
+ const ts = Number(sample.ts);
498
+ const value = Number(sample.value);
499
+
500
+ if (!Number.isFinite(ts) || !Number.isFinite(value)) {
501
+ continue;
502
+ }
503
+
504
+ const distance = Math.abs(ts - targetTs);
505
+
506
+ if (distance < bestDistance) {
507
+ bestDistance = distance;
508
+ best = {
509
+ ts,
510
+ value,
511
+ time: sample.time || this._formatDateTime(new Date(ts)),
512
+ };
513
+ }
514
+ }
515
+
516
+ return best;
517
+ },
518
+
519
+ _getOverallDirection(ref24h, ref7d, ref30d, delta24h, delta7d, delta30d) {
520
+ const available = [ref24h ? delta24h : null, ref7d ? delta7d : null, ref30d ? delta30d : null].filter(
521
+ value => value !== null,
522
+ );
523
+
524
+ if (!available.length) {
525
+ return 'not_enough_data';
526
+ }
527
+
528
+ const strongest = available.reduce((prev, current) => (Math.abs(current) > Math.abs(prev) ? current : prev), 0);
529
+
530
+ if (Math.abs(strongest) < 25) {
531
+ return 'stable';
532
+ }
533
+
534
+ return strongest > 0 ? 'rising' : 'falling';
535
+ },
536
+
537
+ _getTrendStatus(delta24h, delta7d, delta30d, ref24h, ref7d, ref30d) {
538
+ if (!ref24h && !ref7d && !ref30d) {
539
+ return 'not_enough_data';
540
+ }
541
+
542
+ if (delta24h > 100 || delta7d > 300 || delta30d > 800) {
543
+ return 'rising_fast';
544
+ }
545
+
546
+ if (delta24h > 50 || delta7d > 150 || delta30d > 400) {
547
+ return 'rising_noticeable';
548
+ }
549
+
550
+ if (delta24h > 25 || delta7d > 75 || delta30d > 200) {
551
+ return 'rising_slowly';
552
+ }
553
+
554
+ if (delta24h < -25 || delta7d < -75 || delta30d < -200) {
555
+ return 'falling';
556
+ }
557
+
558
+ return 'stable';
559
+ },
560
+
561
+ async _writeTrend(trend) {
562
+ await this._setNumber('chemistry.tds.trend.reference_24h_value', trend.ref24h ? trend.ref24h.value : 0);
563
+ await this._setString('chemistry.tds.trend.reference_24h_at', trend.ref24h ? trend.ref24h.time : '');
564
+ await this._setNumber('chemistry.tds.trend.delta_24h', trend.ref24h ? trend.delta24h : 0);
565
+
566
+ await this._setNumber('chemistry.tds.trend.reference_7d_value', trend.ref7d ? trend.ref7d.value : 0);
567
+ await this._setString('chemistry.tds.trend.reference_7d_at', trend.ref7d ? trend.ref7d.time : '');
568
+ await this._setNumber('chemistry.tds.trend.delta_7d', trend.ref7d ? trend.delta7d : 0);
569
+
570
+ await this._setNumber('chemistry.tds.trend.reference_30d_value', trend.ref30d ? trend.ref30d.value : 0);
571
+ await this._setString('chemistry.tds.trend.reference_30d_at', trend.ref30d ? trend.ref30d.time : '');
572
+ await this._setNumber('chemistry.tds.trend.delta_30d', trend.ref30d ? trend.delta30d : 0);
573
+
574
+ await this._setString('chemistry.tds.trend.direction', trend.direction);
575
+ await this._setString('chemistry.tds.trend.status', trend.status);
576
+ },
577
+
578
+ async _evaluateTds(value, reference, trend) {
579
+ let absoluteLevel = 'normal';
580
+
581
+ if (value > 3000) {
582
+ absoluteLevel = 'critical';
583
+ } else if (value > 2000) {
584
+ absoluteLevel = 'high';
585
+ } else if (value > 1500) {
586
+ absoluteLevel = 'elevated';
587
+ }
588
+
589
+ let referenceLevel = 'not_set';
590
+
591
+ if (reference.initialSet) {
592
+ if (reference.delta > 1500) {
593
+ referenceLevel = 'critical';
594
+ } else if (reference.delta > 1000) {
595
+ referenceLevel = 'high';
596
+ } else if (reference.delta > 500) {
597
+ referenceLevel = 'elevated';
598
+ } else {
599
+ referenceLevel = 'normal';
600
+ }
601
+ }
602
+
603
+ let overallStatus = 'ok';
604
+ let actionRequired = false;
605
+ let recommendation = I18n.translate('TDS value and trend are currently unremarkable.');
606
+
607
+ if (absoluteLevel === 'critical' || referenceLevel === 'critical') {
608
+ overallStatus = 'critical';
609
+ actionRequired = true;
610
+ recommendation = I18n.translate(
611
+ 'TDS is very high. Check water care and seriously consider a partial water change.',
612
+ );
613
+ } else if (absoluteLevel === 'high' || referenceLevel === 'high' || trend.status === 'rising_fast') {
614
+ overallStatus = 'high';
615
+ actionRequired = true;
616
+ recommendation = I18n.translate(
617
+ 'TDS is high or rising quickly. Check water load, chemical usage and consider fresh water or a partial water change.',
618
+ );
619
+ } else if (
620
+ absoluteLevel === 'elevated' ||
621
+ referenceLevel === 'elevated' ||
622
+ trend.status === 'rising_noticeable'
623
+ ) {
624
+ overallStatus = 'elevated';
625
+ actionRequired = true;
626
+ recommendation = I18n.translate(
627
+ 'TDS is elevated or rising noticeably. Observe the trend and check water load and chemical additions.',
628
+ );
629
+ } else if (trend.status === 'not_enough_data') {
630
+ overallStatus = 'not_enough_data';
631
+ actionRequired = false;
632
+ recommendation = I18n.translate(
633
+ 'Not enough TDS history is available yet. Collect more valid measurements.',
634
+ );
635
+ } else if (trend.status === 'falling') {
636
+ overallStatus = 'falling';
637
+ actionRequired = false;
638
+ recommendation = I18n.translate(
639
+ 'TDS is falling. This can be plausible after fresh water or a partial water change.',
640
+ );
641
+ }
642
+
643
+ return {
644
+ absoluteLevel,
645
+ referenceLevel,
646
+ overallStatus,
647
+ actionRequired,
648
+ recommendation,
649
+ };
650
+ },
651
+
652
+ async _writeEvaluation(evaluation) {
653
+ await this._setString('chemistry.tds.evaluation.absolute_level', evaluation.absoluteLevel);
654
+ await this._setString('chemistry.tds.evaluation.reference_level', evaluation.referenceLevel);
655
+ await this._setString('chemistry.tds.evaluation.overall_status', evaluation.overallStatus);
656
+ await this._setString('chemistry.tds.evaluation.recommendation', evaluation.recommendation);
657
+ await this._setBool('chemistry.tds.evaluation.action_required', evaluation.actionRequired);
658
+ },
659
+
660
+ async _writeOutputs(value, reference, trend, evaluation) {
661
+ const text =
662
+ `${I18n.translate('Current TDS value')}: ${Math.round(value)} ppm. ` +
663
+ `24h: ${trend.ref24h ? this._formatDelta(trend.delta24h) : I18n.translate('not enough data')}, ` +
664
+ `7d: ${trend.ref7d ? this._formatDelta(trend.delta7d) : I18n.translate('not enough data')}, ` +
665
+ `30d: ${trend.ref30d ? this._formatDelta(trend.delta30d) : I18n.translate('not enough data')}. ` +
666
+ `${evaluation.recommendation}`;
667
+
668
+ const html =
669
+ `<div>` +
670
+ `<b>${I18n.translate('Current TDS value')}:</b> ${Math.round(value)} ppm<br>` +
671
+ `<b>${I18n.translate('Initial reference')}:</b> ${reference.initialSet ? `${Math.round(reference.initialValue)} ppm` : I18n.translate('not set')}<br>` +
672
+ `<b>${I18n.translate('Delta since reference')}:</b> ${reference.initialSet ? this._formatDelta(reference.delta) : I18n.translate('not available')}<br>` +
673
+ `<b>24h:</b> ${trend.ref24h ? `${Math.round(trend.ref24h.value)} ppm / ${this._formatDelta(trend.delta24h)}` : I18n.translate('not enough data')}<br>` +
674
+ `<b>7d:</b> ${trend.ref7d ? `${Math.round(trend.ref7d.value)} ppm / ${this._formatDelta(trend.delta7d)}` : I18n.translate('not enough data')}<br>` +
675
+ `<b>30d:</b> ${trend.ref30d ? `${Math.round(trend.ref30d.value)} ppm / ${this._formatDelta(trend.delta30d)}` : I18n.translate('not enough data')}<br>` +
676
+ `<b>${I18n.translate('Trend status')}:</b> ${trend.status}<br>` +
677
+ `<b>${I18n.translate('Overall status')}:</b> ${evaluation.overallStatus}<br>` +
678
+ `<b>${I18n.translate('Recommendation')}:</b> ${this._escapeHtml(evaluation.recommendation)}` +
679
+ `</div>`;
680
+
681
+ const json = {
682
+ current: Math.round(value),
683
+ unit: 'ppm',
684
+ reference: {
685
+ initial: reference.initialSet ? Math.round(reference.initialValue) : null,
686
+ delta: reference.initialSet ? Math.round(reference.delta) : null,
687
+ },
688
+ trend_24h: {
689
+ reference: trend.ref24h ? Math.round(trend.ref24h.value) : null,
690
+ delta: trend.ref24h ? Math.round(trend.delta24h) : null,
691
+ },
692
+ trend_7d: {
693
+ reference: trend.ref7d ? Math.round(trend.ref7d.value) : null,
694
+ delta: trend.ref7d ? Math.round(trend.delta7d) : null,
695
+ },
696
+ trend_30d: {
697
+ reference: trend.ref30d ? Math.round(trend.ref30d.value) : null,
698
+ delta: trend.ref30d ? Math.round(trend.delta30d) : null,
699
+ },
700
+ direction: trend.direction,
701
+ trend_status: trend.status,
702
+ absolute_level: evaluation.absoluteLevel,
703
+ reference_level: evaluation.referenceLevel,
704
+ overall_status: evaluation.overallStatus,
705
+ action_required: evaluation.actionRequired,
706
+ recommendation: evaluation.recommendation,
707
+ };
708
+
709
+ await this._setString('chemistry.tds.outputs.summary_text', text);
710
+ await this._setString('chemistry.tds.outputs.summary_html', html);
711
+ await this._setString('chemistry.tds.outputs.summary_json', JSON.stringify(json));
712
+ },
713
+
714
+ async _writeDisabled(reason) {
715
+ await this._setBool('chemistry.tds.measurement.allowed', false);
716
+ await this._setString('chemistry.tds.measurement.ignored_reason', reason);
717
+ await this._setString('chemistry.tds.evaluation.overall_status', 'disabled');
718
+ await this._setString('chemistry.tds.evaluation.recommendation', I18n.translate('TDS evaluation is disabled.'));
719
+ await this._setBool('chemistry.tds.evaluation.action_required', false);
720
+ await this._setString('chemistry.tds.debug.last_reason', reason);
721
+ await this._setString('chemistry.tds.debug.last_update', this._formatDateTime(new Date()));
722
+ },
723
+
724
+ async _writeInvalid(recommendation, reason) {
725
+ await this._setBool('chemistry.tds.input.source_valid', false);
726
+ await this._setBool('chemistry.tds.input.value_valid', false);
727
+ await this._setBool('chemistry.tds.measurement.allowed', false);
728
+ await this._setString('chemistry.tds.measurement.ignored_reason', reason);
729
+ await this._setString('chemistry.tds.evaluation.overall_status', 'invalid');
730
+ await this._setString('chemistry.tds.evaluation.recommendation', recommendation);
731
+ await this._setBool('chemistry.tds.evaluation.action_required', false);
732
+ await this._setString('chemistry.tds.debug.last_reason', reason);
733
+ await this._setString('chemistry.tds.debug.last_update', this._formatDateTime(new Date()));
734
+ },
735
+
736
+ async _readString(id) {
737
+ const state = await this.adapter.getStateAsync(id);
738
+ return String(state?.val ?? '');
739
+ },
740
+
741
+ async _readNumber(id) {
742
+ const state = await this.adapter.getStateAsync(id);
743
+ const value = Number(state?.val);
744
+ return Number.isFinite(value) ? value : 0;
745
+ },
746
+
747
+ async _readNumberOrNull(id) {
748
+ const state = await this.adapter.getStateAsync(id);
749
+ const value = Number(state?.val);
750
+ return Number.isFinite(value) ? value : null;
751
+ },
752
+
753
+ async _readBoolean(id) {
754
+ const state = await this.adapter.getStateAsync(id);
755
+ return !!state?.val;
756
+ },
757
+
758
+ async _readJsonArray(id) {
759
+ const state = await this.adapter.getStateAsync(id);
760
+
761
+ try {
762
+ const parsed = JSON.parse(String(state?.val || '[]'));
763
+ return Array.isArray(parsed) ? parsed : [];
764
+ } catch {
765
+ return [];
766
+ }
767
+ },
768
+
769
+ async _setString(id, value) {
770
+ await this.adapter.setStateChangedAsync(id, { val: String(value ?? ''), ack: true });
771
+ },
772
+
773
+ async _setNumber(id, value) {
774
+ const numberValue = Number(value);
775
+ await this.adapter.setStateChangedAsync(id, { val: Number.isFinite(numberValue) ? numberValue : 0, ack: true });
776
+ },
777
+
778
+ async _setBool(id, value) {
779
+ await this.adapter.setStateChangedAsync(id, { val: !!value, ack: true });
780
+ },
781
+
782
+ _formatDelta(value) {
783
+ const rounded = Math.round(Number(value) || 0);
784
+ return `${rounded >= 0 ? '+' : ''}${rounded} ppm`;
785
+ },
786
+
787
+ _formatDateTime(date) {
788
+ return date.toLocaleString('de-DE', {
789
+ year: 'numeric',
790
+ month: '2-digit',
791
+ day: '2-digit',
792
+ hour: '2-digit',
793
+ minute: '2-digit',
794
+ second: '2-digit',
795
+ });
796
+ },
797
+
798
+ _parseGermanDateTime(value) {
799
+ const match = String(value).match(/^(\d{2})\.(\d{2})\.(\d{4}),?\s+(\d{2}):(\d{2}):(\d{2})$/);
800
+
801
+ if (!match) {
802
+ return null;
803
+ }
804
+
805
+ const [, day, month, year, hour, minute, second] = match;
806
+ return new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
807
+ },
808
+
809
+ _escapeHtml(value) {
810
+ return String(value ?? '')
811
+ .replace(/&/g, '&amp;')
812
+ .replace(/</g, '&lt;')
813
+ .replace(/>/g, '&gt;')
814
+ .replace(/"/g, '&quot;')
815
+ .replace(/'/g, '&#039;');
816
+ },
817
+
818
+ cleanup() {
819
+ if (this.evalTimer) {
820
+ this.adapter.clearTimeout(this.evalTimer);
821
+ this.evalTimer = null;
822
+ }
823
+
824
+ this.adapter = null;
825
+ this.sourceStateId = '';
826
+ this.pumpStartTs = 0;
827
+ },
828
+ };
829
+
830
+ module.exports = chemistryTdsHelper;