iobroker.poolcontrol 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,450 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * heatHelper
5
+ * -------------------------------------------------------------
6
+ * - Steuert Heizung/Wärmepumpe basierend auf Pooltemperatur
7
+ * - Respektiert:
8
+ * - status.season_active
9
+ * - control.pump.maintenance_active (Vorrang / Block)
10
+ * - pump.mode (nur im Automatikbetrieb)
11
+ * - Kann entweder:
12
+ * - eine schaltbare Steckdose (socket) oder
13
+ * - einen bool Steuer-State (boolean) bedienen
14
+ * - Erzeugt zusätzlich ein internes Signal:
15
+ * - heat.heating_request (read-only) => kann extern ausgewertet werden
16
+ * - Pumpen-Nachlaufzeit (min) wird berücksichtigt
17
+ * - Ownership-Schutz: Pumpe wird nur ausgeschaltet, wenn heatHelper sie vorher selbst eingeschaltet hat
18
+ * -------------------------------------------------------------
19
+ */
20
+
21
+ const heatHelper = {
22
+ adapter: null,
23
+
24
+ // dynamische Steuer-ID (foreign)
25
+ _heatControlForeignId: '',
26
+ _afterrunTimer: null,
27
+
28
+ // Ownership / Merker
29
+ _ownsPump: false,
30
+ _desiredHeat: null,
31
+ _lastEval: 0,
32
+
33
+ init(adapter) {
34
+ this.adapter = adapter;
35
+
36
+ // lokale States überwachen
37
+ this.adapter.subscribeStates('heat.control_active');
38
+ this.adapter.subscribeStates('heat.control_type');
39
+ this.adapter.subscribeStates('heat.control_object_id');
40
+ this.adapter.subscribeStates('heat.target_temperature');
41
+ this.adapter.subscribeStates('heat.max_temperature');
42
+ this.adapter.subscribeStates('heat.pump_afterrun_minutes');
43
+
44
+ // Abhängigkeiten
45
+ this.adapter.subscribeStates('status.season_active');
46
+ this.adapter.subscribeStates('pump.mode');
47
+ this.adapter.subscribeStates('pump.pump_switch');
48
+ this.adapter.subscribeStates('temperature.surface.current');
49
+
50
+ // Vorrangschaltung / Wartung
51
+ this.adapter.subscribeStates('control.pump.maintenance_active');
52
+
53
+ // ggf. vorhandene Foreign-ID abonnieren
54
+ this._refreshForeignSubscription().catch(err =>
55
+ this.adapter.log.warn(`[heatHelper] Foreign-Subscription Fehler: ${err.message}`),
56
+ );
57
+
58
+ this._safeEvaluate('init');
59
+ this.adapter.log.info('[heatHelper] Initialisierung abgeschlossen.');
60
+ },
61
+
62
+ async handleStateChange(id, state) {
63
+ if (!state) {
64
+ return;
65
+ }
66
+
67
+ try {
68
+ // Wenn die Ziel-Objekt-ID geändert wurde: Foreign subscription anpassen
69
+ if (id.endsWith('heat.control_object_id') || id.endsWith('heat.control_type')) {
70
+ await this._refreshForeignSubscription();
71
+ await this._safeEvaluate('control_target_changed');
72
+ return;
73
+ }
74
+
75
+ // Alles andere: neu bewerten
76
+ if (
77
+ id.endsWith('heat.control_active') ||
78
+ id.endsWith('heat.target_temperature') ||
79
+ id.endsWith('heat.max_temperature') ||
80
+ id.endsWith('heat.pump_afterrun_minutes') ||
81
+ id.endsWith('status.season_active') ||
82
+ id.endsWith('pump.mode') ||
83
+ id.endsWith('temperature.surface.current') ||
84
+ id.endsWith('control.pump.maintenance_active')
85
+ ) {
86
+ await this._safeEvaluate('state_change');
87
+ return;
88
+ }
89
+
90
+ // Wenn jemand die Pumpe extern schaltet: Ownership ggf. zurücknehmen
91
+ if (id.endsWith('pump.pump_switch')) {
92
+ const pumpOn = !!state.val;
93
+ if (!pumpOn && this._ownsPump) {
94
+ // wenn Pumpe aus geht obwohl wir "ownen", Ownership verlieren
95
+ this._ownsPump = false;
96
+ }
97
+ await this._safeEvaluate('pump_switch_changed');
98
+ return;
99
+ }
100
+ } catch (err) {
101
+ this.adapter.log.warn(`[heatHelper] Fehler in handleStateChange: ${err.message}`);
102
+ }
103
+ },
104
+
105
+ // -------------------------------------------------------------
106
+ // Core
107
+ // -------------------------------------------------------------
108
+ async _evaluate(_sourceTag = '') {
109
+ const now = Date.now();
110
+ if (now - this._lastEval < 250) {
111
+ return;
112
+ }
113
+ this._lastEval = now;
114
+
115
+ const seasonActive = !!(await this.adapter.getStateAsync('status.season_active'))?.val;
116
+ const maintenanceActive = !!(await this.adapter.getStateAsync('control.pump.maintenance_active'))?.val;
117
+
118
+ const pumpMode = (await this.adapter.getStateAsync('pump.mode'))?.val || 'auto';
119
+ const heatEnabled = !!(await this.adapter.getStateAsync('heat.control_active'))?.val;
120
+
121
+ const poolTempRaw = (await this.adapter.getStateAsync('temperature.surface.current'))?.val;
122
+ const poolTemp = Number(poolTempRaw);
123
+
124
+ const targetTemp = Number((await this.adapter.getStateAsync('heat.target_temperature'))?.val ?? 26);
125
+ const maxTemp = Number((await this.adapter.getStateAsync('heat.max_temperature'))?.val ?? 30);
126
+
127
+ const afterrunMin = Math.max(
128
+ 0,
129
+ Number((await this.adapter.getStateAsync('heat.pump_afterrun_minutes'))?.val ?? 0) || 0,
130
+ );
131
+
132
+ const controlType = (await this.adapter.getStateAsync('heat.control_type'))?.val || 'socket';
133
+ const controlObjectId = (await this.adapter.getStateAsync('heat.control_object_id'))?.val || '';
134
+
135
+ // --- Hard conditions / Blocker ---
136
+ if (!seasonActive) {
137
+ return this._applyBlockedState('season_inactive', 'Poolsaison ist inaktiv', afterrunMin);
138
+ }
139
+
140
+ // Vorrangschaltung: Wartung blockiert IMMER
141
+ if (maintenanceActive) {
142
+ return this._applyBlockedState(
143
+ 'maintenance_active',
144
+ 'Wartungsmodus aktiv (Control hat Vorrang)',
145
+ afterrunMin,
146
+ );
147
+ }
148
+
149
+ // Heizungssteuerung deaktiviert
150
+ if (!heatEnabled) {
151
+ return this._applyOffState('heat_disabled', 'Heizungssteuerung deaktiviert', afterrunMin);
152
+ }
153
+
154
+ // Pumpenmodus: nur Automatik
155
+ if (pumpMode !== 'auto') {
156
+ return this._applyBlockedState(
157
+ 'mode_not_auto',
158
+ `Pumpenmodus ist '${pumpMode}' (Heizung arbeitet nur in Automatik)`,
159
+ afterrunMin,
160
+ );
161
+ }
162
+
163
+ // Sensor plausibel?
164
+ if (!Number.isFinite(poolTemp)) {
165
+ return this._applyBlockedState(
166
+ 'no_pool_temp',
167
+ 'Keine gültige Pooltemperatur (temperature.surface.current)',
168
+ afterrunMin,
169
+ );
170
+ }
171
+
172
+ // Sicherheitsabschaltung: MaxTemp überschritten
173
+ if (poolTemp >= maxTemp) {
174
+ return this._applyOffState(
175
+ 'max_temp_reached',
176
+ `Max-Temperatur erreicht (${poolTemp.toFixed(1)} °C ≥ ${maxTemp} °C)`,
177
+ afterrunMin,
178
+ );
179
+ }
180
+
181
+ // --- Heating logic (simple hysteresis-free) ---
182
+ // Einschalten: unter Zieltemperatur
183
+ // Ausschalten: bei Zieltemperatur erreicht/überschritten
184
+ const shouldHeat = poolTemp < targetTemp;
185
+
186
+ if (shouldHeat) {
187
+ return this._startHeating({
188
+ reason: `Heizen: Pool ${poolTemp.toFixed(1)} °C < Ziel ${targetTemp} °C`,
189
+ controlType,
190
+ controlObjectId,
191
+ });
192
+ }
193
+
194
+ return this._stopHeating({
195
+ reason: `Ziel erreicht: Pool ${poolTemp.toFixed(1)} °C ≥ Ziel ${targetTemp} °C`,
196
+ controlType,
197
+ controlObjectId,
198
+ afterrunMin,
199
+ });
200
+ },
201
+
202
+ // -------------------------------------------------------------
203
+ // State transitions
204
+ // -------------------------------------------------------------
205
+ async _startHeating({ reason, controlType, controlObjectId }) {
206
+ if (this._desiredHeat === true) {
207
+ // nur Status/Reason ggf. aktualisieren
208
+ await this._setHeatStates({
209
+ active: true,
210
+ blocked: false,
211
+ mode: 'heating',
212
+ reason,
213
+ info: `control_type=${controlType}, target=${controlObjectId || '(leer)'}`,
214
+ heatingRequest: true,
215
+ });
216
+ return;
217
+ }
218
+
219
+ this._desiredHeat = true;
220
+
221
+ // Nachlauf ggf. abbrechen
222
+ if (this._afterrunTimer) {
223
+ clearTimeout(this._afterrunTimer);
224
+ this._afterrunTimer = null;
225
+ }
226
+
227
+ // Heizung einschalten (wenn ID gesetzt)
228
+ await this._setHeatingDevice(true, controlObjectId);
229
+
230
+ // Pumpe einschalten (ownership)
231
+ await this._ensurePumpOn();
232
+
233
+ await this._setHeatStates({
234
+ active: true,
235
+ blocked: false,
236
+ mode: 'heating',
237
+ reason,
238
+ info: `Heizung EIN | control_type=${controlType}`,
239
+ heatingRequest: true,
240
+ });
241
+
242
+ this.adapter.log.info(`[heatHelper] Heizung EIN (${reason})`);
243
+ },
244
+
245
+ async _stopHeating({ reason, controlType, controlObjectId, afterrunMin }) {
246
+ if (this._desiredHeat === false) {
247
+ // nur Status/Reason ggf. aktualisieren
248
+ await this._setHeatStates({
249
+ active: false,
250
+ blocked: false,
251
+ mode: 'off',
252
+ reason,
253
+ info: `control_type=${controlType}`,
254
+ heatingRequest: false,
255
+ });
256
+ return;
257
+ }
258
+
259
+ this._desiredHeat = false;
260
+
261
+ // Heizung aus
262
+ await this._setHeatingDevice(false, controlObjectId);
263
+
264
+ // Signal für andere Systeme
265
+ await this._setHeatStates({
266
+ active: false,
267
+ blocked: false,
268
+ mode: 'afterrun',
269
+ reason,
270
+ info: `Heizung AUS | Nachlauf=${afterrunMin} min | control_type=${controlType}`,
271
+ heatingRequest: false,
272
+ });
273
+
274
+ // Pumpen-Nachlauf nur, wenn wir die Pumpe eingeschaltet hatten
275
+ await this._startAfterrunIfNeeded(afterrunMin, reason);
276
+
277
+ this.adapter.log.info(`[heatHelper] Heizung AUS (${reason})`);
278
+ },
279
+
280
+ async _applyBlockedState(mode, reason, afterrunMin) {
281
+ // blockiert => Heizung aus, Request false
282
+ await this._setHeatingDevice(false, (await this.adapter.getStateAsync('heat.control_object_id'))?.val || '');
283
+
284
+ await this._setHeatStates({
285
+ active: false,
286
+ blocked: true,
287
+ mode,
288
+ reason,
289
+ info: 'Heizung blockiert',
290
+ heatingRequest: false,
291
+ });
292
+
293
+ // Nachlauf ggf. (nur wenn wir ownen)
294
+ await this._startAfterrunIfNeeded(afterrunMin, reason);
295
+ },
296
+
297
+ async _applyOffState(mode, reason, afterrunMin) {
298
+ // off => Heizung aus, Request false
299
+ await this._setHeatingDevice(false, (await this.adapter.getStateAsync('heat.control_object_id'))?.val || '');
300
+
301
+ await this._setHeatStates({
302
+ active: false,
303
+ blocked: false,
304
+ mode,
305
+ reason,
306
+ info: 'Heizung AUS',
307
+ heatingRequest: false,
308
+ });
309
+
310
+ await this._startAfterrunIfNeeded(afterrunMin, reason);
311
+ },
312
+
313
+ // -------------------------------------------------------------
314
+ // Pump handling (ownership protected)
315
+ // -------------------------------------------------------------
316
+ async _ensurePumpOn() {
317
+ try {
318
+ const pumpState = await this.adapter.getStateAsync('pump.pump_switch');
319
+ const isOn = !!pumpState?.val;
320
+
321
+ if (!isOn) {
322
+ // wir schalten sie ein => ownership true
323
+ this._ownsPump = true;
324
+ await this.adapter.setStateAsync('pump.pump_switch', { val: true, ack: false });
325
+ await this.adapter.setStateAsync('heat.afterrun_active', { val: false, ack: true });
326
+ }
327
+ } catch (err) {
328
+ this.adapter.log.warn(`[heatHelper] Konnte Pumpe nicht einschalten: ${err.message}`);
329
+ }
330
+ },
331
+
332
+ async _startAfterrunIfNeeded(afterrunMin, reason) {
333
+ // nur wenn wir die Pumpe vorher selbst eingeschaltet haben
334
+ if (!this._ownsPump) {
335
+ await this.adapter.setStateAsync('heat.afterrun_active', { val: false, ack: true });
336
+ return;
337
+ }
338
+
339
+ // wenn keine Nachlaufzeit: sofort aus
340
+ if (!afterrunMin || afterrunMin <= 0) {
341
+ await this._stopPumpNow('no_afterrun');
342
+ return;
343
+ }
344
+
345
+ // Timer neu starten
346
+ if (this._afterrunTimer) {
347
+ clearTimeout(this._afterrunTimer);
348
+ this._afterrunTimer = null;
349
+ }
350
+
351
+ await this.adapter.setStateAsync('heat.afterrun_active', { val: true, ack: true });
352
+
353
+ const holdMs = Math.round(afterrunMin * 60 * 1000);
354
+ this.adapter.log.debug(`[heatHelper] Pumpen-Nachlauf gestartet: ${afterrunMin} min (${reason})`);
355
+
356
+ this._afterrunTimer = setTimeout(async () => {
357
+ // Wenn inzwischen wieder Heizbedarf aktiv ist -> Nachlauf abbrechen
358
+ if (this._desiredHeat === true) {
359
+ this.adapter.log.debug('[heatHelper] Nachlauf abgebrochen – Heizen wieder aktiv.');
360
+ return;
361
+ }
362
+ await this._stopPumpNow('afterrun_done');
363
+ }, holdMs);
364
+ },
365
+
366
+ async _stopPumpNow(tag) {
367
+ try {
368
+ await this.adapter.setStateAsync('pump.pump_switch', { val: false, ack: false });
369
+ } catch (err) {
370
+ this.adapter.log.warn(`[heatHelper] Konnte Pumpe nicht ausschalten: ${err.message}`);
371
+ } finally {
372
+ this._ownsPump = false;
373
+ await this.adapter.setStateAsync('heat.afterrun_active', { val: false, ack: true });
374
+ this.adapter.log.info(`[heatHelper] Pumpe AUS (${tag})`);
375
+ }
376
+ },
377
+
378
+ // -------------------------------------------------------------
379
+ // Heating device control (foreign state)
380
+ // -------------------------------------------------------------
381
+ async _setHeatingDevice(on, foreignId) {
382
+ const id = String(foreignId || '').trim();
383
+ if (!id) {
384
+ // Kein Ziel => nur internes heating_request als Signal
385
+ return;
386
+ }
387
+
388
+ try {
389
+ await this.adapter.setForeignStateAsync(id, { val: !!on, ack: false });
390
+ } catch (err) {
391
+ this.adapter.log.warn(`[heatHelper] Konnte Heizung nicht setzen (${id}): ${err.message}`);
392
+ }
393
+ },
394
+
395
+ async _setHeatStates({ active, blocked, mode, reason, info, heatingRequest }) {
396
+ try {
397
+ await this.adapter.setStateAsync('heat.active', { val: !!active, ack: true });
398
+ await this.adapter.setStateAsync('heat.blocked', { val: !!blocked, ack: true });
399
+ await this.adapter.setStateAsync('heat.mode', { val: String(mode ?? ''), ack: true });
400
+ await this.adapter.setStateAsync('heat.reason', { val: String(reason ?? ''), ack: true });
401
+ await this.adapter.setStateAsync('heat.info', { val: String(info ?? ''), ack: true });
402
+ await this.adapter.setStateAsync('heat.heating_request', { val: !!heatingRequest, ack: true });
403
+ await this.adapter.setStateAsync('heat.last_change', { val: Date.now(), ack: true });
404
+ } catch (err) {
405
+ this.adapter.log.warn(`[heatHelper] Fehler beim Schreiben der Heat-States: ${err.message}`);
406
+ }
407
+ },
408
+
409
+ // -------------------------------------------------------------
410
+ // Foreign subscribe handling
411
+ // -------------------------------------------------------------
412
+ async _refreshForeignSubscription() {
413
+ const id = (await this.adapter.getStateAsync('heat.control_object_id'))?.val || '';
414
+ const nextId = String(id).trim();
415
+
416
+ if (nextId && nextId !== this._heatControlForeignId) {
417
+ // neue ID abonnieren
418
+ try {
419
+ this.adapter.subscribeForeignStates(nextId);
420
+ this.adapter.log.info(`[heatHelper] Subscribed Foreign-Heat-Control: "${nextId}"`);
421
+ } catch (err) {
422
+ this.adapter.log.warn(`[heatHelper] Konnte Foreign-State nicht abonnieren (${nextId}): ${err.message}`);
423
+ }
424
+ this._heatControlForeignId = nextId;
425
+ }
426
+
427
+ if (!nextId) {
428
+ this._heatControlForeignId = '';
429
+ }
430
+ },
431
+
432
+ async _safeEvaluate(tag) {
433
+ try {
434
+ await this._evaluate(tag);
435
+ } catch (err) {
436
+ this.adapter.log.warn(`[heatHelper] Evaluate-Fehler (${tag}): ${err.message}`);
437
+ }
438
+ },
439
+
440
+ cleanup() {
441
+ if (this._afterrunTimer) {
442
+ clearTimeout(this._afterrunTimer);
443
+ this._afterrunTimer = null;
444
+ }
445
+ this._ownsPump = false;
446
+ this._desiredHeat = null;
447
+ },
448
+ };
449
+
450
+ module.exports = heatHelper;
@@ -214,6 +214,15 @@ const pumpHelper = {
214
214
  status = 'EIN (Zeitsteuerung)';
215
215
  }
216
216
  break;
217
+
218
+ case 'heatHelper':
219
+ try {
220
+ const reason = (await this.adapter.getStateAsync('pump.reason'))?.val || '';
221
+ status = reason ? `EIN (${reason})` : 'EIN (Heizung)';
222
+ } catch {
223
+ status = 'EIN (Heizung)';
224
+ }
225
+ break;
217
226
  }
218
227
  }
219
228
  await this.adapter.setStateChangedAsync('pump.status', {
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * aiChemistryHelpStates.js
5
+ * ----------------------------------------------------------
6
+ * Legt die States für die KI-Chemie-Hilfe an.
7
+ *
8
+ * Zweck:
9
+ * - Manuelle Auswahl eines beobachteten Pool-Problems
10
+ * - Ausgabe eines erklärenden Hilfetextes (ohne Dosierung)
11
+ *
12
+ * Struktur:
13
+ * ai.chemistry_help.*
14
+ *
15
+ * Hinweis:
16
+ * - Reine Informationsfunktion
17
+ * - Keine Steuerung, keine Automatik, keine Sprachausgabe
18
+ * ----------------------------------------------------------
19
+ */
20
+
21
+ /**
22
+ * Erstellt alle States für die AI-Chemie-Hilfe.
23
+ *
24
+ * @param {import('iobroker').Adapter} adapter - ioBroker Adapterinstanz
25
+ */
26
+ async function createAiChemistryHelpStates(adapter) {
27
+ adapter.log.debug('[aiChemistryHelpStates] Initialisierung gestartet');
28
+
29
+ // ----------------------------------------------------------
30
+ // Channel: ai.chemistry_help
31
+ // ----------------------------------------------------------
32
+ await adapter.setObjectNotExistsAsync('ai.chemistry_help', {
33
+ type: 'channel',
34
+ common: {
35
+ name: 'Chemie-Hilfe (Erklärungen & Ursachen)',
36
+ },
37
+ native: {},
38
+ });
39
+
40
+ // ----------------------------------------------------------
41
+ // Auswahl des Problems (manuell)
42
+ // ----------------------------------------------------------
43
+ await adapter.setObjectNotExistsAsync('ai.chemistry_help.issue', {
44
+ type: 'state',
45
+ common: {
46
+ name: 'Chemie-Hilfe: Problem auswählen',
47
+ desc: 'Manuelle Auswahl eines beobachteten Pool-Problems zur Anzeige allgemeiner Erklärungen.',
48
+ type: 'string',
49
+ role: 'value',
50
+ read: true,
51
+ write: true,
52
+ def: 'none',
53
+ states: {
54
+ none: 'Kein Problem ausgewählt',
55
+
56
+ // pH-Wert
57
+ ph_low: 'pH-Wert ist zu niedrig',
58
+ ph_high: 'pH-Wert ist zu hoch',
59
+
60
+ // Chlor / Desinfektion
61
+ chlor_low: 'Chlorwert ist zu niedrig',
62
+ chlor_high: 'Chlorwert ist zu hoch',
63
+ chlor_no_effect: 'Chlor steigt trotz Zugabe nicht',
64
+ chlor_smell: 'Starker Chlorgeruch trotz Messwert',
65
+
66
+ // Wasserbild / Optik
67
+ water_green: 'Wasser ist grün',
68
+ water_cloudy: 'Wasser ist trüb / grau / milchig',
69
+ algae_visible: 'Algen an Wänden oder Boden sichtbar',
70
+ foam_on_surface: 'Schaumbildung auf der Wasseroberfläche',
71
+
72
+ // Badegefühl / Stabilität
73
+ skin_eye_irritation: 'Haut- oder Augenreizungen beim Baden',
74
+ values_unstable: 'Wasserwerte sind häufig instabil',
75
+
76
+ // Unsicherheit
77
+ unknown_problem: 'Problem unklar / nicht eindeutig',
78
+ },
79
+ },
80
+ native: {},
81
+ });
82
+
83
+ // ----------------------------------------------------------
84
+ // Erklärungstext (wird später vom Helper gefüllt)
85
+ // ----------------------------------------------------------
86
+ await adapter.setObjectNotExistsAsync('ai.chemistry_help.help_text', {
87
+ type: 'state',
88
+ common: {
89
+ name: 'Chemie-Hilfe: Erklärung',
90
+ desc: 'Erklärender Text zu Ursachen und allgemeinen Lösungsansätzen (keine Dosierung, keine Steuerung).',
91
+ type: 'string',
92
+ role: 'text',
93
+ read: true,
94
+ write: false,
95
+ def: '',
96
+ },
97
+ native: {},
98
+ });
99
+
100
+ // ----------------------------------------------------------
101
+ // Zeitpunkt der letzten Auswahl
102
+ // ----------------------------------------------------------
103
+ await adapter.setObjectNotExistsAsync('ai.chemistry_help.last_issue_time', {
104
+ type: 'state',
105
+ common: {
106
+ name: 'Chemie-Hilfe: Letzte Auswahl',
107
+ desc: 'Zeitpunkt der letzten Auswahl eines Chemie-Hilfe-Problems.',
108
+ type: 'number',
109
+ role: 'value.time',
110
+ read: true,
111
+ write: false,
112
+ def: 0,
113
+ },
114
+ native: {},
115
+ });
116
+
117
+ adapter.log.debug('[aiChemistryHelpStates] Initialisierung abgeschlossen');
118
+ }
119
+
120
+ module.exports = {
121
+ createAiChemistryHelpStates,
122
+ };