// -------- 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) // c31..c56 block (enthält C52) const C_BLOCK_STATE = 'tuya.0.bf751cd35a131c1418tick.107'; // NEU: Ziel-State für Außentemperatur in javascript-Instanz const OUTDOOR_STATE = 'javascript.0.heizung.Aussentemperatur'; // Heizkurven-Punkte (gerundet) const T1_OUT = -6.0, VL1 = 46; const T2_OUT = 10.0, VL2 = 38; // p58-Linie (Deine Vorgaben) // Basispunkte: -2°C -> 4.5 ; +6°C -> 3.0 const P_PX1 = -2.0, P_PY1 = 4.5; const P_PX2 = 6.0, P_PY2 = 3.0; // p58 Grenzen const P58_MIN = 3.0; const P58_MAX = 4.7; // lineare Kennlinie Heizkurve const b = (VL2 - VL1) / (T2_OUT - T1_OUT); const a = VL1 - b * T1_OUT; // Sicherheits-/Schreibparameter const MIN_VL = 38; // nie unter 38°C const COOLDOWN = 60; // Sek. Mindestabstand zwischen Vorlauf-writes const HYST = 0.5; // Hysterese in °C (auf float-Anforderungen angewandt) const MAX_STEP_UP = 1.0; // max Erhöhung pro write (+1°C) // p58-spezifisch const P_COOLDOWN = 300; // Sek. Mindestabstand zwischen p58-writes let lastPWriteTime = 0; let lastPValueTenths = null; // zuletzt geschriebener p58-Wert in Zehntel (z.B. 31 für 3.1) // P45-spezifisch (Kompressorleistung) const P45_COOLDOWN = 60; // Sek. Mindestabstand für P45 writes let lastP45WriteTime = 0; let lastP45Value = null; // zuletzt geschriebener P45-Wert als Integer let lastWriteTime = 0; let lastSetVal = null; // integer (aktueller gesetzter Sollwert) - wird beim Start aus TARGET_STATE gelesen // -------- 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 -------- // Wenn dein Block p31..p60 ist, dann gilt: // index für pX = X - 31 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; } // -------- Neue Funktion: P45 aus Außentemperatur (stufig, nach deiner Vorgabe) -------- // Festlegung: // t_out < -1.0 => 90 // -1.0 <= t_out < 2.0 => 80 // 2.0 <= t_out < 5.0 => 70 // t_out >= 5.0 => 60 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.0) return 80; if (t_out >= 2.0 && t_out < 5.0) return 70; return 60; } // -------- p58 & P45 Berechnung & Update (mit festen Indices) -------- function p58_from_outdoor(t_out) { // lineare Interpolation zwischen (P_PX1,P_PY1) und (P_PX2,P_PY2) const pb = (P_PY2 - P_PY1) / (P_PX2 - P_PX1); const pa = P_PY1 - pb * P_PX1; let y = pa + pb * t_out; // Begrenzung auf [P58_MIN, P58_MAX] 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; // read current block 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; // Prüfe Array-Länge if (IDX_P58 < 0 || IDX_P58 >= words.length || IDX_P45 < 0 || IDX_P45 >= words.length) { log('[p58/p45] Blockgröße unerwartet. Erwartete Indices: p58=' + IDX_P58 + ', p45=' + IDX_P45 + ' ; words.length=' + words.length); return; } // --- Berechne gewünschte Werte --- const desiredP58 = p58_from_outdoor(outdoorTemp); const newP58Tenths = Math.round(desiredP58 * 10); // z.B. 3.1 -> 31 // P45 jetzt stufig über p45_from_outdoor const desiredP45 = p45_from_outdoor(outdoorTemp); const newP45Val = (desiredP45 === null) ? null : Math.round(desiredP45); // integer 60/70/80/90 // Bestimme aktuelle Werte aus words (interpretiere: p58 stored in Zehntel, P45 wahrscheinlich plain integer) 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; } // Cooldown-Prüfungen if (needP58Change && !pCooldownOk()) { log('[p58/p45] p58 Cooldown aktiv -> p58-Update übersprungen (evtl P45 separat)'); } 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; } // Update words (Kopie) 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'); } // Backwrite wenn nötig 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'})`); } else { log('[p58/p45] Nach Checks keine Änderungen vorgenommen'); } } // -------- restliche Heiz-Logik (unverändert) -------- 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 -> send command 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); } }); } 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}`); // NEU: Außentemperatur zusätzlich in javascript-Objekt schreiben if (tout !== null && !isNaN(tout)) { // auf 1 Nachkommastelle runden (optional) const toutRounded = Math.round(tout * 10) / 10; setState(OUTDOOR_STATE, toutRounded, true); // ack=true, weil reiner Messwert } // ---- NEU: C52 prüfen (c31..c56 block) ---- 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}`); // deine Regel: words[21] === 0 -> Trinkwasser aktiv if (c52raw === 0) dhwActive = true; } 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 Update (zusammen, mit festen Indices) --- await updateP58andP45BlockIfNeeded(tout); // Wenn Trinkwasser aktiv (dhwActive), dann Vorlaufänderungen sperren if (dhwActive) { log('[HK-step] Trinkwassererwärmung aktiv (C52 words[21]==0) -> Vorlaufänderungen werden unterdrückt'); return; } // Ziel-VL berechnen (float) let vlCalc = vl_from_outdoor(tout); if (vlCalc < MIN_VL) vlCalc = MIN_VL; // Wenn lastSetVal noch unbekannt: initial schreiben if (lastSetVal === null) { const candidateInt = Math.round(vlCalc); if (!cooldownOk()) return; await doWrite(candidateInt); return; } 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) { let allowedFloat = Math.min(lastSetVal + MAX_STEP_UP, vlCalc); const toWrite = Math.round(allowedFloat); if (toWrite === lastSetVal) { log('[HK-step] After limiting increase, rounded value equals lastSet -> nothing to do'); return; } if (!cooldownOk()) return; await doWrite(toWrite); return; } else if (vlCalc < lastSetVal - 1e-9) { if (ist_vl === null) { log('[HK-step] Ist-VL unbekannt -> Skip reduction to avoid undershoot.'); return; } const targetRounded = Math.round(vlCalc); const diff = ist_vl - targetRounded; if (diff >= 1.0) { log(`[HK-step] Reduction BLOCKED: ist_vl ${ist_vl}°C - target ${targetRounded}°C = ${diff.toFixed(2)} >= 1.0`); return; } else { if (!cooldownOk()) return; if (targetRounded === lastSetVal) { log('[HK-step] targetRounded equals lastSetVal -> nothing to do'); return; } await doWrite(targetRounded); return; } } else { log('[HK-step] keine signifikante Änderung'); return; } } // Subscribe + init let timer = null; initLastSetVal(); const sub = on({id: SRC_STATE, change: 'ne'}, () => { if (timer) clearTimeout(timer); timer = setTimeout(() => { runCycle(); timer = null; }, 200); }); runCycle(); onStop(() => { if (sub) sub(); if (timer) clearTimeout(timer); });