iobroker.poolcontrol 1.3.14 → 1.3.17

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