iobroker.poolcontrol 1.3.9 → 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,703 @@
1
+ 'use strict';
2
+
3
+ const { I18n } = require('@iobroker/adapter-core');
4
+
5
+ /**
6
+ * chemistryPhHelper
7
+ * -------------------------------------------------------------
8
+ * pH evaluation helper for PoolControl.
9
+ *
10
+ * Scope:
11
+ * - Manual pH input
12
+ * - External ioBroker state as pH input
13
+ * - Plausibility validation
14
+ * - Measurement location logic
15
+ * - pH recommendation text
16
+ * - Manual mixing run
17
+ *
18
+ * No automatic dosing.
19
+ * No chemical actuator control.
20
+ * -------------------------------------------------------------
21
+ */
22
+
23
+ const chemistryPhHelper = {
24
+ adapter: null,
25
+
26
+ sourceStateId: '',
27
+ evalTimer: null,
28
+ mixTimer: null,
29
+ mixEndTs: 0,
30
+ mixStartedPump: false,
31
+ pumpStartTs: 0,
32
+
33
+ init(adapter) {
34
+ this.adapter = adapter;
35
+
36
+ void this._subscribeStates();
37
+ void this._loadSourceState();
38
+ this._scheduleEvaluation('init', 250);
39
+
40
+ this.adapter.log.debug('[chemistryPhHelper] Initialized');
41
+ },
42
+
43
+ async _subscribeStates() {
44
+ const ids = [
45
+ 'chemistry.ph.enabled',
46
+ 'chemistry.ph.input.source_mode',
47
+ 'chemistry.ph.input.source_state_id',
48
+ 'chemistry.ph.input.manual_value',
49
+ 'chemistry.ph.evaluation.target_min',
50
+ 'chemistry.ph.evaluation.target_max',
51
+ 'chemistry.ph.measurement.location',
52
+ 'chemistry.ph.measurement.flow_required',
53
+ 'chemistry.ph.measurement.stabilization_time_sec',
54
+ 'chemistry.ph.mix.start',
55
+ 'chemistry.ph.mix.runtime_minutes',
56
+ 'pump.pump_switch',
57
+ 'pump.active_helper',
58
+ 'status.season_active',
59
+ ];
60
+
61
+ for (const id of ids) {
62
+ await this.adapter.subscribeStatesAsync(id);
63
+ }
64
+
65
+ this.adapter.log.debug('[chemistryPhHelper] Own states subscribed');
66
+ },
67
+
68
+ async _loadSourceState() {
69
+ const stateId = await this._readString('chemistry.ph.input.source_state_id');
70
+
71
+ if (!stateId) {
72
+ this.sourceStateId = '';
73
+ return;
74
+ }
75
+
76
+ this.sourceStateId = stateId;
77
+
78
+ try {
79
+ this.adapter.subscribeForeignStates(stateId);
80
+ this.adapter.log.debug(`[chemistryPhHelper] Subscribed foreign pH source: ${stateId}`);
81
+ } catch (err) {
82
+ this.adapter.log.warn(
83
+ `[chemistryPhHelper] Could not subscribe foreign pH source "${stateId}": ${err.message}`,
84
+ );
85
+ }
86
+ },
87
+
88
+ async handleStateChange(id, state) {
89
+ if (!state) {
90
+ return;
91
+ }
92
+
93
+ try {
94
+ if (id.endsWith('chemistry.ph.input.source_state_id') && state.ack === false) {
95
+ await this._handleSourceStateChanged(String(state.val || ''));
96
+ return;
97
+ }
98
+
99
+ if (id.endsWith('chemistry.ph.mix.start') && state.ack === false && state.val === true) {
100
+ await this._startMixingRun();
101
+ await this.adapter.setStateChangedAsync('chemistry.ph.mix.start', { val: false, ack: true });
102
+ return;
103
+ }
104
+
105
+ if (this.sourceStateId && id === this.sourceStateId) {
106
+ await this._handleIncomingValue('state', state.val);
107
+ return;
108
+ }
109
+
110
+ if (this._isRelevantOwnState(id)) {
111
+ this._scheduleEvaluation(`state_change:${id}`, 250);
112
+ }
113
+ } catch (err) {
114
+ this.adapter.log.warn(`[chemistryPhHelper] Error in handleStateChange: ${err.message}`);
115
+ }
116
+ },
117
+
118
+ _isRelevantOwnState(id) {
119
+ const relevant = [
120
+ 'chemistry.ph.enabled',
121
+ 'chemistry.ph.input.source_mode',
122
+ 'chemistry.ph.input.manual_value',
123
+ 'chemistry.ph.evaluation.target_min',
124
+ 'chemistry.ph.evaluation.target_max',
125
+ 'chemistry.ph.measurement.location',
126
+ 'chemistry.ph.measurement.flow_required',
127
+ 'chemistry.ph.measurement.stabilization_time_sec',
128
+ 'pump.pump_switch',
129
+ 'pump.active_helper',
130
+ 'status.season_active',
131
+ ];
132
+
133
+ return relevant.some(stateId => id.endsWith(stateId));
134
+ },
135
+
136
+ async _handleSourceStateChanged(newStateId) {
137
+ if (this.sourceStateId && this.sourceStateId !== newStateId) {
138
+ try {
139
+ this.adapter.unsubscribeForeignStates(this.sourceStateId);
140
+ this.adapter.log.debug(`[chemistryPhHelper] Unsubscribed old pH source: ${this.sourceStateId}`);
141
+ } catch (err) {
142
+ this.adapter.log.debug(`[chemistryPhHelper] Could not unsubscribe old pH source: ${err.message}`);
143
+ }
144
+ }
145
+
146
+ this.sourceStateId = newStateId;
147
+
148
+ if (newStateId) {
149
+ try {
150
+ this.adapter.subscribeForeignStates(newStateId);
151
+ await this._setBool('chemistry.ph.input.source_valid', true);
152
+ await this._setString(
153
+ 'chemistry.ph.input.source_status',
154
+ I18n.translate('pH source state configured.'),
155
+ );
156
+ this.adapter.log.debug(`[chemistryPhHelper] Subscribed new pH source: ${newStateId}`);
157
+ } catch (err) {
158
+ await this._setBool('chemistry.ph.input.source_valid', false);
159
+ await this._setString(
160
+ 'chemistry.ph.input.source_status',
161
+ I18n.translate('pH source state could not be subscribed.'),
162
+ );
163
+ this.adapter.log.warn(
164
+ `[chemistryPhHelper] Could not subscribe new pH source "${newStateId}": ${err.message}`,
165
+ );
166
+ }
167
+ } else {
168
+ await this._setBool('chemistry.ph.input.source_valid', false);
169
+ await this._setString('chemistry.ph.input.source_status', I18n.translate('No pH source state configured.'));
170
+ }
171
+
172
+ this._scheduleEvaluation('source_state_changed', 250);
173
+ },
174
+
175
+ async _handleIncomingValue(source, rawValue) {
176
+ const mode = await this._readString('chemistry.ph.input.source_mode');
177
+
178
+ if (mode !== 'state') {
179
+ return;
180
+ }
181
+
182
+ await this._processValue(source, rawValue, 'external_state');
183
+ },
184
+
185
+ _scheduleEvaluation(reason, delayMs = 250) {
186
+ if (this.evalTimer) {
187
+ this.adapter.clearTimeout(this.evalTimer);
188
+ this.evalTimer = null;
189
+ }
190
+
191
+ this.evalTimer = this.adapter.setTimeout(() => {
192
+ this.evalTimer = null;
193
+ void this._evaluate(reason);
194
+ }, delayMs);
195
+ },
196
+
197
+ async _evaluate(reason = '') {
198
+ try {
199
+ const enabled = await this._readBoolean('chemistry.ph.enabled');
200
+
201
+ if (!enabled) {
202
+ await this._setString('chemistry.ph.evaluation.status', 'disabled');
203
+ await this._setString(
204
+ 'chemistry.ph.evaluation.recommendation',
205
+ I18n.translate('pH evaluation is disabled.'),
206
+ );
207
+ await this._setBool('chemistry.ph.evaluation.action_required', false);
208
+ await this._setBool('chemistry.ph.measurement.allowed', false);
209
+ await this._setString('chemistry.ph.measurement.ignored_reason', 'disabled');
210
+ await this._setString('chemistry.ph.debug.last_reason', reason || 'disabled');
211
+ await this._setString('chemistry.ph.debug.last_update', this._formatDateTime(new Date()));
212
+ return;
213
+ }
214
+
215
+ const mode = await this._readString('chemistry.ph.input.source_mode');
216
+
217
+ if (mode === 'disabled') {
218
+ await this._setBool('chemistry.ph.input.source_valid', false);
219
+ await this._setBool('chemistry.ph.input.value_valid', false);
220
+ await this._setString('chemistry.ph.input.source_status', I18n.translate('pH input is disabled.'));
221
+ await this._setString('chemistry.ph.evaluation.status', 'disabled');
222
+ await this._setString(
223
+ 'chemistry.ph.evaluation.recommendation',
224
+ I18n.translate('No pH input source is active.'),
225
+ );
226
+ await this._setBool('chemistry.ph.evaluation.action_required', false);
227
+ await this._setBool('chemistry.ph.measurement.allowed', false);
228
+ await this._setString('chemistry.ph.measurement.ignored_reason', 'source_disabled');
229
+ await this._setString('chemistry.ph.debug.last_reason', reason || 'source_disabled');
230
+ await this._setString('chemistry.ph.debug.last_update', this._formatDateTime(new Date()));
231
+ return;
232
+ }
233
+
234
+ if (mode === 'manual') {
235
+ const manualValue = await this._readNumber('chemistry.ph.input.manual_value');
236
+ await this._processValue('manual', manualValue, reason || 'manual_value');
237
+ return;
238
+ }
239
+
240
+ if (mode === 'state') {
241
+ const sourceStateId = await this._readString('chemistry.ph.input.source_state_id');
242
+
243
+ if (!sourceStateId) {
244
+ await this._setBool('chemistry.ph.input.source_valid', false);
245
+ await this._setBool('chemistry.ph.input.value_valid', false);
246
+ await this._setString(
247
+ 'chemistry.ph.input.source_status',
248
+ I18n.translate('No pH source state configured.'),
249
+ );
250
+ await this._setString('chemistry.ph.evaluation.status', 'invalid');
251
+ await this._setString(
252
+ 'chemistry.ph.evaluation.recommendation',
253
+ I18n.translate('No valid pH source is configured.'),
254
+ );
255
+ await this._setBool('chemistry.ph.evaluation.action_required', false);
256
+ await this._setBool('chemistry.ph.measurement.allowed', false);
257
+ await this._setString('chemistry.ph.measurement.ignored_reason', 'missing_source_state');
258
+ await this._setString('chemistry.ph.debug.last_reason', reason || 'missing_source_state');
259
+ await this._setString('chemistry.ph.debug.last_update', this._formatDateTime(new Date()));
260
+ return;
261
+ }
262
+
263
+ let sourceState = null;
264
+
265
+ try {
266
+ sourceState = await this.adapter.getForeignStateAsync(sourceStateId);
267
+ } catch (err) {
268
+ await this._setBool('chemistry.ph.input.source_valid', false);
269
+ await this._setBool('chemistry.ph.input.value_valid', false);
270
+ await this._setString(
271
+ 'chemistry.ph.input.source_status',
272
+ I18n.translate('pH source state could not be read.'),
273
+ );
274
+ await this._setString('chemistry.ph.evaluation.status', 'invalid');
275
+ await this._setString(
276
+ 'chemistry.ph.evaluation.recommendation',
277
+ I18n.translate('The configured pH source could not be read.'),
278
+ );
279
+ await this._setBool('chemistry.ph.evaluation.action_required', false);
280
+ await this._setBool('chemistry.ph.measurement.allowed', false);
281
+ await this._setString('chemistry.ph.measurement.ignored_reason', 'source_read_error');
282
+ await this._setString('chemistry.ph.debug.last_reason', `source_read_error: ${err.message}`);
283
+ await this._setString('chemistry.ph.debug.last_update', this._formatDateTime(new Date()));
284
+ return;
285
+ }
286
+
287
+ if (!sourceState) {
288
+ await this._setBool('chemistry.ph.input.source_valid', false);
289
+ await this._setBool('chemistry.ph.input.value_valid', false);
290
+ await this._setString(
291
+ 'chemistry.ph.input.source_status',
292
+ I18n.translate('pH source state does not exist.'),
293
+ );
294
+ await this._setString('chemistry.ph.evaluation.status', 'invalid');
295
+ await this._setString(
296
+ 'chemistry.ph.evaluation.recommendation',
297
+ I18n.translate('The configured pH source state does not exist.'),
298
+ );
299
+ await this._setBool('chemistry.ph.evaluation.action_required', false);
300
+ await this._setBool('chemistry.ph.measurement.allowed', false);
301
+ await this._setString('chemistry.ph.measurement.ignored_reason', 'source_not_found');
302
+ await this._setString('chemistry.ph.debug.last_reason', reason || 'source_not_found');
303
+ await this._setString('chemistry.ph.debug.last_update', this._formatDateTime(new Date()));
304
+ return;
305
+ }
306
+
307
+ await this._processValue('state', sourceState.val, reason || 'state_value');
308
+ return;
309
+ }
310
+
311
+ await this._setString('chemistry.ph.evaluation.status', 'invalid');
312
+ await this._setString('chemistry.ph.evaluation.recommendation', I18n.translate('Unknown pH source mode.'));
313
+ await this._setBool('chemistry.ph.evaluation.action_required', false);
314
+ await this._setString('chemistry.ph.measurement.ignored_reason', 'unknown_source_mode');
315
+ await this._setString('chemistry.ph.debug.last_reason', reason || 'unknown_source_mode');
316
+ await this._setString('chemistry.ph.debug.last_update', this._formatDateTime(new Date()));
317
+ } catch (err) {
318
+ this.adapter.log.warn(`[chemistryPhHelper] Evaluation failed: ${err.message}`);
319
+ }
320
+ },
321
+
322
+ async _processValue(source, rawValue, reason) {
323
+ const now = new Date();
324
+ const value = Number(rawValue);
325
+
326
+ await this._setString('chemistry.ph.input.last_value_at', this._formatDateTime(now));
327
+
328
+ if (source === 'manual') {
329
+ await this._setBool('chemistry.ph.input.source_valid', true);
330
+ await this._setString('chemistry.ph.input.source_status', I18n.translate('Manual pH value is used.'));
331
+ } else {
332
+ await this._setBool('chemistry.ph.input.source_valid', true);
333
+ await this._setString('chemistry.ph.input.source_status', I18n.translate('External pH source is valid.'));
334
+ }
335
+
336
+ const valueValid = Number.isFinite(value) && value >= 0 && value <= 14;
337
+
338
+ await this._setNumber('chemistry.ph.input.current_value', Number.isFinite(value) ? value : 0);
339
+ await this._setBool('chemistry.ph.input.value_valid', valueValid);
340
+
341
+ if (!valueValid) {
342
+ await this._setString('chemistry.ph.evaluation.status', 'invalid');
343
+ await this._setString(
344
+ 'chemistry.ph.evaluation.recommendation',
345
+ I18n.translate('The pH value is invalid. Please check the measurement or sensor.'),
346
+ );
347
+ await this._setBool('chemistry.ph.evaluation.action_required', false);
348
+ await this._setBool('chemistry.ph.measurement.allowed', false);
349
+ await this._setString('chemistry.ph.measurement.ignored_reason', 'invalid_value');
350
+ await this._setString('chemistry.ph.debug.last_reason', reason || 'invalid_value');
351
+ await this._setString('chemistry.ph.debug.last_update', this._formatDateTime(now));
352
+ return;
353
+ }
354
+
355
+ const measurementAllowed = await this._checkMeasurementAllowed(now);
356
+
357
+ if (!measurementAllowed.allowed) {
358
+ await this._setString('chemistry.ph.evaluation.status', measurementAllowed.status);
359
+ await this._setString('chemistry.ph.evaluation.recommendation', measurementAllowed.recommendation);
360
+ await this._setBool('chemistry.ph.evaluation.action_required', false);
361
+ await this._setBool('chemistry.ph.measurement.allowed', false);
362
+ await this._setString('chemistry.ph.measurement.ignored_reason', measurementAllowed.reason);
363
+ await this._setString('chemistry.ph.debug.last_reason', reason || measurementAllowed.reason);
364
+ await this._setString('chemistry.ph.debug.last_update', this._formatDateTime(now));
365
+ return;
366
+ }
367
+
368
+ await this._setBool('chemistry.ph.measurement.allowed', true);
369
+ await this._setString('chemistry.ph.measurement.ignored_reason', '');
370
+
371
+ await this._updateHistory(value, now);
372
+ await this._evaluateValue(value);
373
+
374
+ await this._setString('chemistry.ph.debug.last_reason', reason || 'value_processed');
375
+ await this._setString('chemistry.ph.debug.last_update', this._formatDateTime(now));
376
+ },
377
+
378
+ async _checkMeasurementAllowed(now) {
379
+ const location = await this._readString('chemistry.ph.measurement.location');
380
+ const flowRequired = await this._readBoolean('chemistry.ph.measurement.flow_required');
381
+ const pumpRunning = await this._readBoolean('pump.pump_switch');
382
+
383
+ await this._setBool('chemistry.ph.measurement.pump_running', pumpRunning);
384
+
385
+ const needsFlow = flowRequired || location === 'measurement_cell' || location === 'pipe_section';
386
+
387
+ if (!needsFlow || location === 'pool' || location === 'manual') {
388
+ await this._setBool('chemistry.ph.measurement.stabilized', true);
389
+ return { allowed: true, reason: '', status: 'ok', recommendation: '' };
390
+ }
391
+
392
+ if (!pumpRunning) {
393
+ this.pumpStartTs = 0;
394
+ await this._setBool('chemistry.ph.measurement.stabilized', false);
395
+
396
+ return {
397
+ allowed: false,
398
+ reason: 'pump_off',
399
+ status: 'waiting_for_pump',
400
+ recommendation: I18n.translate(
401
+ 'pH evaluation is waiting for the pool pump because the sensor is in a measurement section.',
402
+ ),
403
+ };
404
+ }
405
+
406
+ if (!this.pumpStartTs) {
407
+ this.pumpStartTs = now.getTime();
408
+ }
409
+
410
+ const stabilizationSec = Math.max(0, await this._readNumber('chemistry.ph.measurement.stabilization_time_sec'));
411
+ const elapsedSec = Math.floor((now.getTime() - this.pumpStartTs) / 1000);
412
+ const stabilized = elapsedSec >= stabilizationSec;
413
+
414
+ await this._setBool('chemistry.ph.measurement.stabilized', stabilized);
415
+
416
+ if (!stabilized) {
417
+ return {
418
+ allowed: false,
419
+ reason: 'stabilization_pending',
420
+ status: 'waiting_for_stabilization',
421
+ recommendation: I18n.translate(
422
+ 'pH evaluation is waiting until the measurement section has stabilized after pump start.',
423
+ ),
424
+ };
425
+ }
426
+
427
+ return { allowed: true, reason: '', status: 'ok', recommendation: '' };
428
+ },
429
+
430
+ async _updateHistory(value, now) {
431
+ const lastValid = await this._readNumberOrNull('chemistry.ph.input.last_valid_value');
432
+ const lastValidAt = await this._readString('chemistry.ph.input.last_valid_value_at');
433
+
434
+ if (lastValid !== null && lastValidAt) {
435
+ await this._setNumber('chemistry.ph.input.previous_value', lastValid);
436
+ await this._setString('chemistry.ph.input.previous_value_at', lastValidAt);
437
+
438
+ const previousDate = this._parseGermanDateTime(lastValidAt);
439
+
440
+ if (previousDate) {
441
+ const minutes = Math.max(0, Math.round((now.getTime() - previousDate.getTime()) / 60000));
442
+ await this._setNumber('chemistry.ph.input.minutes_since_previous_value', minutes);
443
+ }
444
+ }
445
+
446
+ await this._setNumber('chemistry.ph.input.last_valid_value', value);
447
+ await this._setString('chemistry.ph.input.last_valid_value_at', this._formatDateTime(now));
448
+ },
449
+
450
+ async _evaluateValue(value) {
451
+ const min = await this._readNumber('chemistry.ph.evaluation.target_min');
452
+ const max = await this._readNumber('chemistry.ph.evaluation.target_max');
453
+
454
+ if (value < min) {
455
+ await this._setString('chemistry.ph.evaluation.status', 'low');
456
+ await this._setString(
457
+ 'chemistry.ph.evaluation.recommendation',
458
+ I18n.translate(
459
+ 'pH value is too low. Check whether pH plus should be added according to the product instructions. Then circulate and measure again.',
460
+ ),
461
+ );
462
+ await this._setBool('chemistry.ph.evaluation.action_required', true);
463
+ return;
464
+ }
465
+
466
+ if (value > max) {
467
+ await this._setString('chemistry.ph.evaluation.status', 'high');
468
+ await this._setString(
469
+ 'chemistry.ph.evaluation.recommendation',
470
+ I18n.translate(
471
+ 'pH value is too high. Check whether pH minus should be added according to the product instructions. Then circulate and measure again.',
472
+ ),
473
+ );
474
+ await this._setBool('chemistry.ph.evaluation.action_required', true);
475
+ return;
476
+ }
477
+
478
+ await this._setString('chemistry.ph.evaluation.status', 'ok');
479
+ await this._setString(
480
+ 'chemistry.ph.evaluation.recommendation',
481
+ I18n.translate('pH value is within the target range. No action is required.'),
482
+ );
483
+ await this._setBool('chemistry.ph.evaluation.action_required', false);
484
+ },
485
+
486
+ async _startMixingRun() {
487
+ const enabled = await this._readBoolean('chemistry.ph.enabled');
488
+
489
+ if (!enabled) {
490
+ await this._setString(
491
+ 'chemistry.ph.mix.status',
492
+ I18n.translate('Mixing run was not started because pH evaluation is disabled.'),
493
+ );
494
+ return;
495
+ }
496
+
497
+ const seasonActive = await this._readBoolean('status.season_active');
498
+
499
+ if (!seasonActive) {
500
+ await this._setString(
501
+ 'chemistry.ph.mix.status',
502
+ I18n.translate('Mixing run was not started because the pool season is inactive.'),
503
+ );
504
+ return;
505
+ }
506
+
507
+ const runtimeMin = Math.max(0, await this._readNumber('chemistry.ph.mix.runtime_minutes'));
508
+
509
+ if (runtimeMin <= 0) {
510
+ await this._setString(
511
+ 'chemistry.ph.mix.status',
512
+ I18n.translate('Mixing run was not started because no runtime is configured.'),
513
+ );
514
+ return;
515
+ }
516
+
517
+ const activeHelper = await this._readString('pump.active_helper');
518
+
519
+ if (activeHelper && activeHelper !== 'chemistryPhHelper') {
520
+ await this._setString(
521
+ 'chemistry.ph.mix.status',
522
+ I18n.translate('Mixing run was not started because another helper currently controls the pump.'),
523
+ );
524
+ return;
525
+ }
526
+
527
+ const pumpRunning = await this._readBoolean('pump.pump_switch');
528
+
529
+ this.mixStartedPump = !pumpRunning;
530
+ this.mixEndTs = Date.now() + runtimeMin * 60000;
531
+
532
+ await this._setBool('chemistry.ph.mix.active', true);
533
+ await this._setBool('chemistry.ph.mix.started_by_helper', this.mixStartedPump);
534
+ await this._setNumber('chemistry.ph.mix.remaining_minutes', runtimeMin);
535
+
536
+ if (this.mixStartedPump) {
537
+ await this.adapter.setStateChangedAsync('pump.active_helper', { val: 'chemistryPhHelper', ack: true });
538
+ await this.adapter.setStateChangedAsync('pump.pump_switch', { val: true, ack: false });
539
+ }
540
+
541
+ await this._setString(
542
+ 'chemistry.ph.mix.status',
543
+ I18n.translate('pH mixing run started. No chemicals are dosed automatically.'),
544
+ );
545
+
546
+ this._scheduleMixTick();
547
+ },
548
+
549
+ _scheduleMixTick() {
550
+ if (this.mixTimer) {
551
+ this.adapter.clearTimeout(this.mixTimer);
552
+ this.mixTimer = null;
553
+ }
554
+
555
+ this.mixTimer = this.adapter.setTimeout(() => {
556
+ void this._mixTick();
557
+ }, 30000);
558
+ },
559
+
560
+ async _mixTick() {
561
+ const active = await this._readBoolean('chemistry.ph.mix.active');
562
+
563
+ if (!active) {
564
+ this._clearMixTimer();
565
+ return;
566
+ }
567
+
568
+ const remainingMs = this.mixEndTs - Date.now();
569
+ const remainingMin = Math.max(0, Math.ceil(remainingMs / 60000));
570
+
571
+ await this._setNumber('chemistry.ph.mix.remaining_minutes', remainingMin);
572
+
573
+ if (remainingMs <= 0) {
574
+ await this._finishMixingRun();
575
+ return;
576
+ }
577
+
578
+ this._scheduleMixTick();
579
+ },
580
+
581
+ async _finishMixingRun() {
582
+ this._clearMixTimer();
583
+
584
+ await this._setBool('chemistry.ph.mix.active', false);
585
+ await this._setNumber('chemistry.ph.mix.remaining_minutes', 0);
586
+
587
+ if (this.mixStartedPump) {
588
+ const activeHelper = await this._readString('pump.active_helper');
589
+
590
+ if (!activeHelper || activeHelper === 'chemistryPhHelper') {
591
+ await this.adapter.setStateChangedAsync('pump.pump_switch', { val: false, ack: false });
592
+ await this.adapter.setStateChangedAsync('pump.active_helper', { val: '', ack: true });
593
+ await this._setString(
594
+ 'chemistry.ph.mix.status',
595
+ I18n.translate('pH mixing run finished. Pump was switched off by the pH helper.'),
596
+ );
597
+ } else {
598
+ await this._setString(
599
+ 'chemistry.ph.mix.status',
600
+ I18n.translate(
601
+ 'pH mixing run finished. Pump was not switched off because another helper is active.',
602
+ ),
603
+ );
604
+ }
605
+ } else {
606
+ await this._setString(
607
+ 'chemistry.ph.mix.status',
608
+ I18n.translate(
609
+ 'pH mixing run finished. Pump was already running and was not switched off by the pH helper.',
610
+ ),
611
+ );
612
+ }
613
+
614
+ this.mixStartedPump = false;
615
+ this.mixEndTs = 0;
616
+
617
+ await this._setBool('chemistry.ph.mix.started_by_helper', false);
618
+ },
619
+
620
+ _clearMixTimer() {
621
+ if (this.mixTimer) {
622
+ this.adapter.clearTimeout(this.mixTimer);
623
+ this.mixTimer = null;
624
+ }
625
+ },
626
+
627
+ async _readString(id) {
628
+ const state = await this.adapter.getStateAsync(id);
629
+ return String(state?.val ?? '');
630
+ },
631
+
632
+ async _readNumber(id) {
633
+ const state = await this.adapter.getStateAsync(id);
634
+ const value = Number(state?.val);
635
+ return Number.isFinite(value) ? value : 0;
636
+ },
637
+
638
+ async _readNumberOrNull(id) {
639
+ const state = await this.adapter.getStateAsync(id);
640
+ const value = Number(state?.val);
641
+ return Number.isFinite(value) ? value : null;
642
+ },
643
+
644
+ async _readBoolean(id) {
645
+ const state = await this.adapter.getStateAsync(id);
646
+ return !!state?.val;
647
+ },
648
+
649
+ async _setString(id, value) {
650
+ await this.adapter.setStateChangedAsync(id, { val: String(value ?? ''), ack: true });
651
+ },
652
+
653
+ async _setNumber(id, value) {
654
+ const numberValue = Number(value);
655
+ await this.adapter.setStateChangedAsync(id, { val: Number.isFinite(numberValue) ? numberValue : 0, ack: true });
656
+ },
657
+
658
+ async _setBool(id, value) {
659
+ await this.adapter.setStateChangedAsync(id, { val: !!value, ack: true });
660
+ },
661
+
662
+ _formatDateTime(date) {
663
+ return date.toLocaleString('de-DE', {
664
+ year: 'numeric',
665
+ month: '2-digit',
666
+ day: '2-digit',
667
+ hour: '2-digit',
668
+ minute: '2-digit',
669
+ second: '2-digit',
670
+ });
671
+ },
672
+
673
+ _parseGermanDateTime(value) {
674
+ const match = String(value).match(/^(\d{2})\.(\d{2})\.(\d{4}),?\s+(\d{2}):(\d{2}):(\d{2})$/);
675
+
676
+ if (!match) {
677
+ return null;
678
+ }
679
+
680
+ const [, day, month, year, hour, minute, second] = match;
681
+ return new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
682
+ },
683
+
684
+ cleanup() {
685
+ if (this.evalTimer) {
686
+ this.adapter.clearTimeout(this.evalTimer);
687
+ this.evalTimer = null;
688
+ }
689
+
690
+ if (this.mixTimer) {
691
+ this.adapter.clearTimeout(this.mixTimer);
692
+ this.mixTimer = null;
693
+ }
694
+
695
+ this.adapter = null;
696
+ this.sourceStateId = '';
697
+ this.mixEndTs = 0;
698
+ this.mixStartedPump = false;
699
+ this.pumpStartTs = 0;
700
+ },
701
+ };
702
+
703
+ module.exports = chemistryPhHelper;