'use strict'; const utils = require('@iobroker/adapter-core'); class HeatingHelper extends utils.Adapter { constructor(options = {}) { super({ ...options, name: 'heating-helper' }); this.rooms = []; this.handlers = []; this.on('ready', this.onReady.bind(this)); this.on('stateChange', this.onStateChange.bind(this)); this.on('unload', this.onUnload.bind(this)); } async onReady() { try { const n = this.config; this.log.info('Heating Helper startet...'); this.z2m = n.z2mPrefix || 'zigbee2mqtt.0'; this.scheduleMode = n.scheduleMode || 'strict'; this.fallbackMode = n.fallbackMode || 'setpoint'; this.fallbackSetpoint = Number(n.fallbackSetpoint ?? 6.0); this.staleMinutes = Number(n.staleMinutes ?? 10); this.globalHysteresis = Number(n.globalHysteresis ?? 0.1); this.globalMinIntervalSec = Number(n.globalMinIntervalSec ?? 60); this.rooms = Array.isArray(n.rooms) ? n.rooms : []; if (!this.rooms.length) { this.log.warn('Keine Räume konfiguriert.'); return; } // Handler je Raum erzeugen for (const room of this.rooms) { const h = this.createRoomHandler(room); if (h) this.handlers.push(h); } // Periodischer Stale-Check this.staleTimer = this.setInterval(() => { const now = Date.now(); for (const h of this.handlers) { if (!h.enableTempForward) continue; if (!h.lastGoodTs) continue; const age = (now - h.lastGoodTs) / 1000; if (!h.fallbackActive && age > (this.staleMinutes * 60)) { h.fallbackActive = true; this.log.warn(`[${h.name}] Fallback aktiviert (stale): Sensor ausgefallen oder keine neuen Werte`); if (this.fallbackMode === 'internal') { this.setStateAsync(h.trvBase + '.temperature_sensor_select', 'internal', true); } else { if (h.prevSetpoint == null) { h.prevSetpoint = this.readNum(h.trvBase + '.occupied_heating_setpoint'); } this.pushSetpoint(h, this.fallbackSetpoint); h.lastReassertTs = Date.now(); this.log.warn(`[${h.name}] Fallback: Setpoint temporär auf ${this.fallbackSetpoint} °C gesetzt`); } } } }, 30 * 1000); this.log.info(`Initialisiert: ${this.handlers.length} Raum-Handler aktiv.`); } catch (e) { this.log.error('onReady error: ' + e); } } createRoomHandler(room) { const name = room.name || 'raum'; const trvBase = room.trvBase; const sensorTemp = room.sensorTemp; const windowContact = room.windowContact; if (!trvBase || !windowContact) { this.log.warn(`[${name}] unvollständige Konfiguration (trvBase/windowContact fehlt)`); return null; } const h = { name, trvBase, sensorTemp, windowContact, openWhen: !!room.openWhen, openDelaySec: Number(room.openDelaySec ?? 60), closeDelaySec: Number(room.closeDelaySec ?? 30), openSetpoint: Number(room.openSetpoint ?? 6.0), enableTempForward: !!room.enableTempForward, // Zustände lastSent: 0, lastPushed: null, lastGoodTs: 0, lastGoodTemp: null, fallbackActive: false, prevSetpoint: null, windowOpenActive: false, weAreSetting: false, lastReassertTs: 0, openTimer: null, closeTimer: null }; // Initial: externen Sensor aktivieren (wenn gewünscht) if (h.enableTempForward) { this.setStateAsync(h.trvBase + '.temperature_sensor_select', 'external', true); // initiale Temperatur pushen const t = this.readNum(sensorTemp); if (this.isPlausibleTemp(t)) { h.lastGoodTemp = t; h.lastGoodTs = Date.now(); this.pushExternalTemp(h, t, /*force*/ true); } else { this.log.warn(`[${h.name}] Keine gültige Starttemperatur vom Sensor gefunden`); } } // Subscriptions // Fensterkontakt this.subscribeForeignStates(h.windowContact); // Setpoint (Zeitplan/Benutzer) this.subscribeForeignStates(h.trvBase + '.occupied_heating_setpoint'); // Sensor-Temp if (h.enableTempForward && h.sensorTemp) { this.subscribeForeignStates(h.sensorTemp); } // TRV Sensorquelle (Watchdog) this.subscribeForeignStates(h.trvBase + '.temperature_sensor_select'); this.log.info(`[${h.name}] Handler aktiv. TRV=${h.trvBase}, Sensor=${h.sensorTemp || '—'}, Fenster=${h.windowContact}`); return h; } onStateChange(id, state) { if (!state || state.ack === undefined) return; for (const h of this.handlers) { // Fensterkontakt if (id === h.windowContact && state.ack !== undefined) { this.onWindowChange(h, state.val === h.openWhen); return; } // Sensor-Temp if (h.enableTempForward && id === h.sensorTemp && state.ack !== undefined) { this.onTempChange(h, Number(state.val)); return; } // TRV Setpoint if (id === h.trvBase + '.occupied_heating_setpoint') { this.onSetpointChange(h, Number(state.val), state.from); return; } // TRV Sensorquelle if (id === h.trvBase + '.temperature_sensor_select') { this.onSensorSelectChange(h, String(state.val || '')); return; } } } onWindowChange(h, isOpen) { // Debounce via Timer if (h.openTimer) { clearTimeout(h.openTimer); h.openTimer = null; } if (h.closeTimer) { clearTimeout(h.closeTimer); h.closeTimer = null; } if (isOpen) { h.openTimer = setTimeout(() => { h.openTimer = null; // Zustand prüfen this.getForeignState(h.windowContact, (err, st) => { if (!st) return; if (st.val === h.openWhen) this.handleWindowOpen(h); }); }, h.openDelaySec * 1000); this.log.info(`[${h.name}] Fenster offen erkannt, Reaktion in ${h.openDelaySec}s`); } else { h.closeTimer = setTimeout(() => { h.closeTimer = null; this.getForeignState(h.windowContact, (err, st) => { if (!st) return; if (st.val !== h.openWhen) this.handleWindowClose(h); }); }, h.closeDelaySec * 1000); this.log.info(`[${h.name}] Fenster geschlossen erkannt, Reaktion in ${h.closeDelaySec}s`); } } handleWindowOpen(h) { if (h.windowOpenActive) return; h.windowOpenActive = true; if (h.prevSetpoint == null) { h.prevSetpoint = this.readNum(h.trvBase + '.occupied_heating_setpoint'); this.log.info(`[${h.name}] Vorherigen Setpoint gesichert: ${h.prevSetpoint} °C`); } this.pushSetpoint(h, h.openSetpoint); h.lastReassertTs = Date.now(); this.log.warn(`[${h.name}] Setpoint temporär auf ${h.openSetpoint} °C abgesenkt (Fenster offen)`); } handleWindowClose(h) { if (!h.windowOpenActive) return; h.windowOpenActive = false; let restore = h.prevSetpoint; if (this.config.scheduleMode === 'passive' && typeof h.lastDesiredSet === 'number') { restore = h.lastDesiredSet; } if (typeof restore === 'number') { this.pushSetpoint(h, restore); this.log.info(`[${h.name}] Setpoint wiederhergestellt: ${restore} °C (Fenster geschlossen)`); } else { this.log.warn(`[${h.name}] Kein vorheriger Setpoint gespeichert, keine Wiederherstellung`); } h.prevSetpoint = null; h.lastDesiredSet = null; } onTempChange(h, t) { if (!this.isPlausibleTemp(t)) { this.log.warn(`[${h.name}] Ignoriere unplausible Temperatur: ${t}`); return; } h.lastGoodTemp = t; h.lastGoodTs = Date.now(); this.pushExternalTemp(h, t, false); if (h.fallbackActive) { this.log.info(`[${h.name}] Sensor wieder verfügbar - beende Fallback`); if (this.fallbackMode === 'internal') { this.setStateAsync(h.trvBase + '.temperature_sensor_select', 'external', true); } else if (this.fallbackMode === 'setpoint' && h.prevSetpoint != null) { this.pushSetpoint(h, h.prevSetpoint); this.log.info(`[${h.name}] Setpoint wiederhergestellt: ${h.prevSetpoint} °C`); } h.fallbackActive = false; } } onSetpointChange(h, val, from) { if (isNaN(val)) return; h.lastDesiredSet = val; if (!h.windowOpenActive) return; if (this.config.scheduleMode !== 'strict') return; if (h.weAreSetting) return; // Strict: bei Abweichung wieder auf openSetpoint ziehen (rate-limited) const diff = Math.abs(val - h.openSetpoint); const age = (Date.now() - h.lastReassertTs) / 1000; if (diff >= 0.3 && age >= 20) { this.pushSetpoint(h, h.openSetpoint); h.lastReassertTs = Date.now(); this.log.warn(`[${h.name}] Strict-Hold: Setpoint während offen wieder auf ${h.openSetpoint} °C gesetzt (abweichend: ${val})`); } } onSensorSelectChange(h, cur) { if (cur !== 'external' && !h.fallbackActive && h.enableTempForward) { this.log.warn(`[${h.name}] TRV hat Sensorquelle geändert (${cur}) - korrigiere auf external`); this.setStateAsync(h.trvBase + '.temperature_sensor_select', 'external', true); const t = h.lastGoodTemp ?? this.readNum(h.sensorTemp); if (this.isPlausibleTemp(t)) this.pushExternalTemp(h, t, true); } } // Helpers readNum(id) { // sync read (best effort) // (ioBroker Adapter-API hat keine sync reads; hier nur für init: wir holen letzten gespeicherten Wert) // besser: getForeignStateAsync mit await; für Kürze: return null; // optional implementieren via callbacks, wenn gewünscht } isPlausibleTemp(t) { return typeof t === 'number' && !isNaN(t) && t >= -10 && t <= 35; } pushExternalTemp(h, t, force) { const now = Date.now(); if (!force) { if ((now - h.lastSent) / 1000 < this.globalMinIntervalSec) return; const val = Math.round(t * 10) / 10; if (h.lastPushed != null && Math.abs(h.lastPushed - val) < this.globalHysteresis) return; this.setStateAsync(h.trvBase + '.external_temperature_input', val, true); h.lastPushed = val; h.lastSent = now; this.log.info(`[${h.name}] Temperatur an TRV gesendet: ${val} °C`); } else { const val = Math.round(t * 10) / 10; this.setStateAsync(h.trvBase + '.external_temperature_input', val, true); h.lastPushed = val; h.lastSent = now; this.log.info(`[${h.name}] Temperatur an TRV gesendet: ${val} °C (force)`); } } pushSetpoint(h, val) { h.weAreSetting = true; this.setStateAsync(h.trvBase + '.occupied_heating_setpoint', val, false).then(() => { setTimeout(() => { h.weAreSetting = false; }, 300); }); } onUnload(callback) { try { if (this.staleTimer) this.clearInterval(this.staleTimer); this.log.info('Adapter wird beendet.'); callback(); } catch (e) { callback(); } } } if (module.parent) { module.exports = (options) => new HeatingHelper(options); } else { new HeatingHelper(); }