// -------- Konfiguration / State-IDs -------- const SRC_STATE = 'tuya.0.bf751cd35a131c1418tick.106'; const TARGET_STATE = 'tuya.0.bf751cd35a131c1418tick.117'; const P_SETTING_STATE = 'tuya.0.bf751cd35a131c1418tick.102'; // p31_p60 block (base64) const C_BLOCK_STATE = 'tuya.0.bf751cd35a131c1418tick.107'; // c31..c56 block (enthält C52) // Raumtemperatur vom Shelly const ROOM_TEMP_STATE = 'shelly.0.shellyhtg3#543204565934#1.Temperature0.Celsius'; // Raum-Solltemperatur (leicht oben änderbar, 1 Nachkommastelle erlaubt) const ROOM_SETPOINT = 24.0; // °C (bei Bedarf anpassen) // Außentemperatur-Spiegel in javascript-Instanz const OUTDOOR_STATE = 'javascript.0.heizung.Aussentemperatur'; // -------- Parameter für Zwangs-Absenkung VL anhand Raumtemp -------- // diffRoom = Raum - Soll // diffRoom >= ROOM_FORCE_VL_HIGH_DIFF -> Vorlaufsoll VOLL auf FORCE_VL_VALUE (Override AN) // diffRoom <= ROOM_FORCE_VL_LOW_DIFF -> Override AUS, wieder normaler Vorlauf const ROOM_FORCE_VL_HIGH_DIFF = 0.3; // K über Soll: ab hier VL-Zwang auf FORCE_VL_VALUE const ROOM_FORCE_VL_LOW_DIFF = -0.3; // K unter Soll: ab hier Override wieder aus const FORCE_VL_VALUE = 10.0; // erzwungener Vorlaufsollwert in °C // -------- Parameter für VL-Korrektur nach Raumtemp -------- // diff = Raum - Soll // diff <= -0.5 K -> +2°C Vorlauf // -0.5 K < diff <= -0.2 K -> +1°C Vorlauf // -0.2 K < diff < 0.1 K -> 0°C Änderung // 0.1 K <= diff < 0.2 K -> -1°C Vorlauf // diff >= 0.2 K -> -2°C Vorlauf const VL_DELTA_THRESH_2UP = -0.5; // deutlich zu kalt const VL_DELTA_THRESH_1UP = -0.2; // etwas zu kalt const VL_DELTA_THRESH_1DOWN = 0.1; // ab hier leicht zu warm -> -1°C const VL_DELTA_THRESH_2DOWN = 0.2; // ab hier deutlich zu warm -> -2°C const VL_OFFSET_PLUS2 = 2.0; // +2°C Vorlauf const VL_OFFSET_PLUS1 = 1.0; // +1°C const VL_OFFSET_ZERO = 0.0; // 0°C const VL_OFFSET_MINUS1 = -1.0; // -1°C const VL_OFFSET_MINUS2 = -2.0; // -2°C // -------- Heizkurven-Punkte (Außentemperatur -> Vorlauf) -------- const T1_OUT = -6.0, VL1 = 44; const T2_OUT = 10.0, VL2 = 38; // p58-Linie (Vorgaben) // Basispunkte: -2°C -> 4.5 ; +6°C -> 3.3 const P_PX1 = -2.0, P_PY1 = 4.5; const P_PX2 = 6.0, P_PY2 = 3.3; // p58 Grenzen const P58_MIN = 3.0; const P58_MAX = 4.7; // lineare Kennlinie Heizkurve (VL = a + b * T_out) const b = (VL2 - VL1) / (T2_OUT - T1_OUT); const a = VL1 - b * T1_OUT; // Sicherheits-/Schreibparameter const MIN_VL = 38; // nie unter 38°C (gilt NICHT, wenn Override aktiv ist) const COOLDOWN = 60; // Sek. Mindestabstand zwischen Vorlauf-writes (nur für Senkung) const HYST = 0.5; // Hysterese in °C (nur für Senkung) const MAX_STEP_DOWN = 1.0; // max Absenkung pro write (-1°C) // max. erlaubter Abstand IstVL - SollVL, um WP-Abschaltung zu vermeiden (nur für Senkung) const MAX_VL_DIFF = 0.9; // K // p58-spezifisch const P_COOLDOWN = 300; // Sek. Mindestabstand zwischen p58-writes let lastPWriteTime = 0; let lastPValueTenths = null; // P45-spezifisch (Kompressorleistung) const P45_COOLDOWN = 60; let lastP45WriteTime = 0; let lastP45Value = null; let lastWriteTime = 0; let lastSetVal = null; // integer Soll-Vorlauf // Merker, ob aktuell VL-Override aktiv ist let forceLowVL = false; // -------- Hilfsfunktionen (Decodieren / Encodieren) -------- function decodeWords(base64) { try { const buf = Buffer.from(base64, 'base64'); const out = []; for (let i = 0; i + 1 < buf.length && out.length < 200; i += 2) { out.push(((buf[i] << 8) | buf[i + 1]) & 0xFFFF); } return out; } catch (e) { log('[HK-step] Base64 decode error: ' + e); return null; } } function encodeWords(words) { const buf = Buffer.alloc(words.length * 2); for (let i = 0; i < words.length; i++) { buf[i*2] = (words[i] >> 8) & 0xFF; buf[i*2+1] = words[i] & 0xFF; } return buf.toString('base64'); } function toSigned(v) { return v > 0x7fff ? v - 0x10000 : v; } // -------- Feste Indices für p31..p60 -------- const IDX_P58 = 58 - 31; // 27 const IDX_P45 = 45 - 31; // 14 // C31..C56 mapping: words[0]=C31 -> C52 ist words[21] const C52_WORD_INDEX_IN_CBLOCK = 21; function pCooldownOk() { const now = Math.floor(Date.now() / 1000); if (lastPWriteTime && (now - lastPWriteTime) < P_COOLDOWN) return false; return true; } function p45CooldownOk() { const now = Math.floor(Date.now() / 1000); if (lastP45WriteTime && (now - lastP45WriteTime) < P45_COOLDOWN) return false; return true; } // -------- P45 & p58 aus Außentemperatur -------- function p45_from_outdoor(t_out) { if (t_out === null || isNaN(t_out)) return null; if (t_out < -1.0) return 90; if (t_out >= -1.0 && t_out < 2) return 80; if (t_out >= 2.0 && t_out < 5) return 70; return 60; } function p58_from_outdoor(t_out) { const pb = (P_PY2 - P_PY1) / (P_PX2 - P_PX1); const pa = P_PY1 - pb * P_PX1; let y = pa + pb * t_out; if (y < P58_MIN) y = P58_MIN; if (y > P58_MAX) y = P58_MAX; return y; } async function updateP58andP45BlockIfNeeded(outdoorTemp) { if (outdoorTemp === null || isNaN(outdoorTemp)) return; const state = await new Promise(res => getState(P_SETTING_STATE, (e,s) => res(s))); if (!state || !state.val) { log('[p58/p45] Setting-State leer oder nicht vorhanden: ' + P_SETTING_STATE); return; } const words = decodeWords(state.val); if (!words) return; if (IDX_P58 < 0 || IDX_P58 >= words.length || IDX_P45 < 0 || IDX_P45 >= words.length) { log('[p58/p45] Blockgröße unerwartet. p58=' + IDX_P58 + ', p45=' + IDX_P45 + ', words.length=' + words.length); return; } const desiredP58 = p58_from_outdoor(outdoorTemp); const newP58Tenths = Math.round(desiredP58 * 10); const desiredP45 = p45_from_outdoor(outdoorTemp); const newP45Val = (desiredP45 === null) ? null : Math.round(desiredP45); const currentP58Tenths = toSigned(words[IDX_P58]); const currentP45Raw = toSigned(words[IDX_P45]); const needP58Change = (currentP58Tenths !== newP58Tenths); const needP45Change = (newP45Val !== null && currentP45Raw !== newP45Val); if (!needP58Change && !needP45Change) { lastPValueTenths = currentP58Tenths; lastP45Value = currentP45Raw; log(`[p58/p45] kein Änderungsbedarf (p58=${(currentP58Tenths/10).toFixed(1)}, p45=${currentP45Raw})`); return; } if (needP58Change && !pCooldownOk()) { log('[p58/p45] p58 Cooldown aktiv -> p58-Update übersprungen'); } if (needP45Change && !p45CooldownOk()) { log('[p58/p45] P45 Cooldown aktiv -> P45-Update übersprungen'); } const willWriteP58 = needP58Change && pCooldownOk(); const willWriteP45 = needP45Change && p45CooldownOk(); if (!willWriteP58 && !willWriteP45) { log('[p58/p45] Keine writes erlaubt wegen Cooldowns -> skip'); return; } const wordsModified = words.slice(); if (willWriteP58) { const clamped = Math.max(Math.round(P58_MIN*10), Math.min(Math.round(P58_MAX*10), newP58Tenths)); wordsModified[IDX_P58] = clamped & 0xFFFF; lastPValueTenths = clamped; lastPWriteTime = Math.floor(Date.now()/1000); log(`[p58/p45] p58 geplant: ${(currentP58Tenths/10).toFixed(1)} -> ${(clamped/10).toFixed(1)} (idx=${IDX_P58})`); } else { log('[p58/p45] p58 write suppressed by cooldown'); } if (willWriteP45) { wordsModified[IDX_P45] = newP45Val & 0xFFFF; lastP45Value = newP45Val; lastP45WriteTime = Math.floor(Date.now()/1000); log(`[p58/p45] P45 geplant: ${currentP45Raw} -> ${newP45Val} (idx=${IDX_P45})`); } else { log('[p58/p45] P45 write suppressed by cooldown'); } if (willWriteP58 || willWriteP45) { const newBase64 = encodeWords(wordsModified); setState(P_SETTING_STATE, newBase64, false); log(`[p58/p45] Backwrite durchgeführt (p58:${willWriteP58 ? 'Y':'N'}, p45:${willWriteP45 ? 'Y':'N'})`); } } // -------- restliche Heiz-Logik -------- function vl_from_outdoor(t_out) { if (t_out === null || isNaN(t_out)) return VL1; let vl = a + b * t_out; if (vl < MIN_VL) vl = MIN_VL; return vl; } function cooldownOk() { const now = Math.floor(Date.now()/1000); if (lastWriteTime && (now - lastWriteTime) < COOLDOWN) { log('[HK-step] Cooldown active: skipping write'); return false; } return true; } async function doWrite(valToWrite) { const tgt = await new Promise(res => getObject(TARGET_STATE, (e,o) => res(o))); if (!tgt) { log('[HK-step] Zielstate nicht gefunden: ' + TARGET_STATE); return false; } if (!tgt.common || !tgt.common.write) { log('[HK-step] Zielstate nicht schreibbar'); return false; } setState(TARGET_STATE, valToWrite, false); // ack=false lastSetVal = valToWrite; lastWriteTime = Math.floor(Date.now()/1000); log(`[HK-step] wrote VL=${valToWrite}°C`); return true; } function initLastSetVal() { getState(TARGET_STATE, (err, state) => { if (err || !state || state.val === undefined || state.val === null) { log('[HK-step] Kein vorhandener Ziel-Wert, lastSetVal bleibt null'); return; } const v = Number(state.val); if (!isNaN(v)) { lastSetVal = Math.round(v); log('[HK-step] initial lastSetVal aus TARGET_STATE = ' + lastSetVal); } }); } // -------- Hauptzyklus -------- async function runCycle() { const st = await new Promise(res => getState(SRC_STATE, (e,s) => res(s))); if (!st || !st.val) { log('[HK-step] source empty'); return; } const words = decodeWords(st.val); if (!words) return; const tout = (words[1] !== undefined && words[1] !== 0x8000) ? toSigned(words[1]) / 10.0 : null; const ist_vl = (words[7] !== undefined && words[7] !== 0x8000) ? toSigned(words[7]) / 10.0 : null; log('[HK-step] outdoor=' + tout + '°C, ist_vl=' + ist_vl + '°C, lastSet=' + lastSetVal); // Außentemperatur in JS-Objekt schreiben if (tout !== null && !isNaN(tout)) { const toutRounded = Math.round(tout * 10) / 10; setState(OUTDOOR_STATE, toutRounded, true); } // C52 prüfen (Trinkwasser aktiv?) let dhwActive = false; try { const stC = await new Promise(res => getState(C_BLOCK_STATE, (e,s) => res(s))); if (stC && stC.val) { const cWords = decodeWords(stC.val); if (cWords && cWords.length > C52_WORD_INDEX_IN_CBLOCK) { const c52raw = toSigned(cWords[C52_WORD_INDEX_IN_CBLOCK]); log('[C52] gelesen words[' + C52_WORD_INDEX_IN_CBLOCK + '] = ' + c52raw); if (c52raw === 0) dhwActive = true; // 0 = Trinkwasser aktiv } else { log('[C52] c-block zu kurz oder ungültig'); } } else { log('[C52] C-Block State leer oder nicht vorhanden: ' + C_BLOCK_STATE); } } catch (e) { log('[C52] Fehler beim Lesen des C-Blocks: ' + e); } // p58 & P45 updaten await updateP58andP45BlockIfNeeded(tout); // Raumtemperatur einlesen let roomTemp = null; try { const stRoom = await new Promise(res => getState(ROOM_TEMP_STATE, (e,s) => res(s))); if (stRoom && stRoom.val !== undefined && stRoom.val !== null) { const v = Number(stRoom.val); if (!isNaN(v)) { roomTemp = Math.round(v * 10) / 10; } } } catch (e) { log('[HK-step] Fehler beim Lesen der Raumtemperatur: ' + e); } // Vorlauf-Basis aus Heizkurve let vlCalc = vl_from_outdoor(tout); // VL anhand Raumtemp fein nachregeln (normale +2/+1/0/-1/-2 Logik) if (roomTemp !== null) { const diff = roomTemp - ROOM_SETPOINT; // positiv = zu warm let offset = VL_OFFSET_ZERO; // Mapping exakt wie kommentiert: // diff <= -0.5 -> +2 // -0.5 < diff <= -0.2 -> +1 // -0.2 < diff < 0.1 -> 0 // 0.1 <= diff < 0.2 -> -1 // diff >= 0.2 -> -2 if (diff <= VL_DELTA_THRESH_2UP) { offset = VL_OFFSET_PLUS2; } else if (diff <= VL_DELTA_THRESH_1UP) { offset = VL_OFFSET_PLUS1; } else if (diff < VL_DELTA_THRESH_1DOWN) { // -0.2 < diff < 0.1 offset = VL_OFFSET_ZERO; } else if (diff < VL_DELTA_THRESH_2DOWN) { // 0.1 <= diff < 0.2 offset = VL_OFFSET_MINUS1; } else { // diff >= 0.2 offset = VL_OFFSET_MINUS2; } const oldVlCalc = vlCalc; vlCalc = vlCalc + offset; log( '[HK-step] Raum=' + roomTemp.toFixed(1) + '°C, Soll=' + ROOM_SETPOINT.toFixed(1) + '°C, diff=' + diff.toFixed(2) + 'K, offset=' + offset.toFixed(1) + '°C -> VL von ' + oldVlCalc.toFixed(1) + '°C auf ' + vlCalc.toFixed(1) + '°C' ); } else { log('[HK-step] Raumtemperatur nicht verfügbar -> keine Offset-Anpassung'); } // Override-Logik für "zu warm" (VL auf 10°C zwingen) if (roomTemp !== null) { const diffRoom = roomTemp - ROOM_SETPOINT; if (diffRoom >= ROOM_FORCE_VL_HIGH_DIFF) { if (!forceLowVL) { log('[Override] Raumdiff ' + diffRoom.toFixed(2) + 'K >= ' + ROOM_FORCE_VL_HIGH_DIFF + 'K -> VL-Zwang auf ' + FORCE_VL_VALUE + '°C AKTIVIERT'); } forceLowVL = true; } else if (diffRoom <= ROOM_FORCE_VL_LOW_DIFF) { if (forceLowVL) { log('[Override] Raumdiff ' + diffRoom.toFixed(2) + 'K <= ' + ROOM_FORCE_VL_LOW_DIFF + 'K -> VL-Zwang auf ' + FORCE_VL_VALUE + '°C DEAKTIVIERT (zurück zu normaler Regelung)'); } forceLowVL = false; } } // MIN_VL nur anwenden, wenn kein Override if (!forceLowVL && vlCalc < MIN_VL) vlCalc = MIN_VL; // Wenn Override aktiv: VL hart auf FORCE_VL_VALUE setzen if (forceLowVL) { const oldVl = vlCalc; vlCalc = FORCE_VL_VALUE; log('[Override] VL-Zwang aktiv -> Vorlaufsoll von ' + oldVl.toFixed(1) + '°C auf ' + vlCalc.toFixed(1) + '°C gesetzt (ohne MIN_VL/Step-Limit/Hysterese)'); } // Wenn Trinkwasser aktiv laut C52, Vorlaufänderungen sperren if (dhwActive) { log('[HK-step] Trinkwassererwärmung aktiv (C52 words[21]==0) -> Vorlaufänderungen werden unterdrückt'); return; } // --- Schreiblogik --- // Initialschreibvorgang if (lastSetVal === null) { const candidateInt = Math.round(vlCalc); // beim allerersten Mal kann ruhig der "normale" Wert gesetzt werden await doWrite(candidateInt); return; } // Wenn Override aktiv: direkt auf vlCalc (z.B. 10°C) schreiben, ohne Hysterese/Step-Limit if (forceLowVL) { const toWriteOverride = Math.round(vlCalc); // sollte FORCE_VL_VALUE sein if (toWriteOverride === lastSetVal) { log('[Override] VL-Zwang aktiv, aber Soll entspricht bereits letztem Wert -> kein Write'); return; } await doWrite(toWriteOverride); return; } // *** ERHÖHUNG: keine Restriktionen, kein Cooldown *** if (vlCalc > lastSetVal + 1e-9) { const toWriteUp = Math.round(vlCalc); if (toWriteUp === lastSetVal) { log('[HK-step] Increase: berechneter neuer Soll entspricht lastSetVal -> kein Write'); return; } log('[HK-step] Increase: lastSetVal=' + lastSetVal + '°C -> ' + toWriteUp + '°C (vlCalc=' + vlCalc.toFixed(1) + '°C)'); await doWrite(toWriteUp); return; } // *** SENKUNG: mit Hysterese, Cooldown, sanften Schritten *** const deltaFloatToLast = Math.abs(vlCalc - lastSetVal); if (deltaFloatToLast < HYST) { log('[HK-step] Delta float < HYST (' + deltaFloatToLast.toFixed(2) + ' < ' + HYST + ') -> skip'); return; } if (vlCalc < lastSetVal - 1e-9) { // Sanfte Reduktion des Soll-Vorlaufs in sicheren Schritten if (ist_vl === null) { log('[HK-step] Ist-VL unbekannt -> Skip reduction to avoid undershoot.'); return; } // gewünschter Wert aus Heizkurve let desired = vlCalc; // 1) Max. Schrittweite nach unten begrenzen (z.B. -1 K) if (lastSetVal - desired > MAX_STEP_DOWN) { desired = lastSetVal - MAX_STEP_DOWN; } // 2) Sicherstellen, dass Soll nicht mehr als MAX_VL_DIFF unter Ist liegt const minTarget = ist_vl - MAX_VL_DIFF; if (desired < minTarget) { desired = minTarget; } // 3) Aufrunden, damit wir nicht zu weit nach unten runden const toWriteDown = Math.ceil(desired); // Wenn das nach der Begrenzung kein echtes Absenken mehr ist -> nix tun if (toWriteDown >= lastSetVal) { log('[HK-step] Reduction: berechneter neuer Soll (' + toWriteDown + '°C) wäre nicht kleiner als lastSetVal (' + lastSetVal + '°C) -> kein Write'); return; } // Sicherheitscheck: Abstand Ist–Soll nochmal prüfen const diffVL = ist_vl - toWriteDown; if (diffVL >= 1.0) { log('[HK-step] Reduction SAFETY: ist_vl ' + ist_vl + '°C - neuer Soll ' + toWriteDown + '°C = ' + diffVL.toFixed(2) + ' >= 1.0 -> Write unterdrückt'); return; } if (!cooldownOk()) return; log('[HK-step] Reduction: ist_vl=' + ist_vl + '°C, lastSetVal=' + lastSetVal + '°C, vlCalc=' + vlCalc.toFixed(1) + '°C, desired=' + desired.toFixed(2) + '°C, write=' + toWriteDown + '°C, diffVL=' + diffVL.toFixed(2) + 'K'); await doWrite(toWriteDown); return; } log('[HK-step] keine signifikante Änderung'); } // Subscribe + init let timer = null; initLastSetVal(); const sub = on({ id: SRC_STATE, change: 'ne' }, () => { if (timer) clearTimeout(timer); timer = setTimeout(() => { runCycle(); timer = null; }, 200); }); // Einmal initial ausführen runCycle(); onStop(() => { if (sub) sub(); if (timer) clearTimeout(timer); });