////////////// TAUPI 4.5 @ Shelly ////////////// // Dynamic Quality-Score Psychrometric Controller & Adaptive Thermal Governor var MAC_OUT = "${sensor_aussen_mac}".toLowerCase(); var MAC_IN = "${sensor_innen_mac}".toLowerCase(); var SCHALTZEIT_SEC = ${schaltzeit}; var LOST_LIMIT = ${lost_connection}; var BATT_LIMIT = ${battery_warngrenze}; var DP_SPREAD_BASE = ${taupunktschwelle}; var MIN_TEMP_FLOOR = ${mindesttemperatur}; var MAX_HEAT_CEIL = ${max_indoor_temp_heating}; var WINTER_IN_TH = ${winter_temp_threshold_in}; var TARGET_SUMMER = ${target_humi_summer}; var TARGET_WINTER = ${target_humi_winter}; var MIN_PAUSE_MIN = ${min_pause_minutes}; var LIM_BASE = ${critical_humi_base}; var LIM_COEFF = ${critical_humi_temp_coeff}; var LIM_REF_T = ${critical_humi_ref_temp}; var LIM_BUF = ${critical_humi_buffer}; var ti = undefined, hi = undefined, di = undefined, bi = undefined, age_in = 999999; var to = undefined, ho = undefined, do_ = undefined, bo = undefined, age_out = 999999; var fan_run_sec = 0, fan_pause_sec = 0, relay_on = false, last_reason = "INIT", last_color = -1; var st_run = 0, st_target = TARGET_SUMMER, st_mold_th = 70.0, st_mold_crit = 0, st_spread = 0; var st_quality = 0, st_max_run_min = 0, st_gov_state = 0; var pid_out = -1, pid_in = -1; function calcDewPoint(T, RH) { if (typeof T !== "number" || typeof RH !== "number" || isNaN(T) || isNaN(RH) || RH <= 0) return undefined; var a = (T >= 0) ? 17.27 : 21.875, b = (T >= 0) ? 237.7 : 265.5; var alpha = (a * T) / (b + T) + Math.log(RH / 100); return (b * alpha) / (a - alpha); } function calcMoldThresh(T) { if (typeof T !== "number" || isNaN(T)) return undefined; return LIM_BASE - LIM_COEFF * (T - LIM_REF_T) - LIM_BUF; } function evaluateDecision() { var telOk = (typeof ti === "number" && typeof to === "number" && typeof hi === "number" && typeof ho === "number" && typeof di === "number" && typeof do_ === "number" && !isNaN(ti) && !isNaN(to) && !isNaN(hi) && !isNaN(ho) && !isNaN(di) && !isNaN(do_) && age_in <= LOST_LIMIT && age_out <= LOST_LIMIT); if (!telOk) return { run: false, reason: "TELEMETRY_INVALID", maxRun: 0, qual: 0, gov: 0 }; if (fan_pause_sec > 0) return { run: false, reason: "COOLDOWN_PAUSE", maxRun: 0, qual: 0, gov: 0 }; var mt = calcMoldThresh(ti); var isMoldCrit = (typeof mt === "number" && hi >= mt); // 1. Frost Floor Check if (ti <= MIN_TEMP_FLOOR && !isMoldCrit) { return { run: false, reason: "FROST_PROTECTION", maxRun: 0, qual: 0, gov: 1 }; } // 2. Adaptive Thermal Governor var reqSpread = DP_SPREAD_BASE; var targetHumi = TARGET_SUMMER; var govState = 2; // Comfort default if (ti > MAX_HEAT_CEIL) { // Warm Zone (> 20°C): Maximize cooling. Prevent heating if To > Ti. govState = 3; if (to > ti) return { run: false, reason: "HEATING_PREVENTED", maxRun: 0, qual: 0, gov: govState }; reqSpread = DP_SPREAD_BASE; targetHumi = TARGET_SUMMER; } else if (ti >= WINTER_IN_TH) { // Comfort Zone (14°C - 20°C) govState = 2; targetHumi = TARGET_SUMMER; if (to < (ti - 5.0)) { reqSpread = DP_SPREAD_BASE + 1.0; // Strong cooling requires prime spread >= 3.0°C } } else { // Cold Zone (< 14°C) govState = 4; targetHumi = TARGET_WINTER; if (to >= ti) { reqSpread = DP_SPREAD_BASE + 0.5; // Warming cellar allowed at spread >= 2.5°C } else { reqSpread = DP_SPREAD_BASE + 1.5; // Further cooling an already cold cellar requires >= 3.5°C } } // 3. Target Humidity Gate if (hi <= targetHumi) { return { run: false, reason: "TARGET_REACHED", maxRun: 0, qual: 0, gov: govState }; } // 4. Dew Point Spread Gate var spread = di - do_; if (spread < reqSpread) { return { run: false, reason: "INSUFFICIENT_SPREAD", maxRun: 0, qual: Math.round(Math.max(0, spread * 20)), gov: govState }; } // 5. Dynamic Quality Runtime Formula // Runtime [min] = (spread - DP_SPREAD_BASE) * 45 min var maxRunMin = 0; if (spread >= 5.0) { maxRunMin = 9999; // Unlimited continuous run } else { maxRunMin = Math.round((spread - DP_SPREAD_BASE) * 45); if (maxRunMin < 15) maxRunMin = 15; } // Cold Zone burst throttle (max 25 min bursts when Ti < 14°C) if (ti < WINTER_IN_TH && maxRunMin > 25) { maxRunMin = 25; } var qualityScore = Math.min(100, Math.round(spread * 20)); return { run: true, reason: "VENTILATING_QUALITY", maxRun: maxRunMin, qual: qualityScore, gov: govState }; } function updateLeds(c) { if (c === last_color) return; last_color = c; var r = 0, g = 40, b = 80, br = 60; if (c === 1) { r = 100; g = 0; b = 0; br = 100; } else if (c === 2) { r = 80; g = 80; b = 0; br = 100; } else if (c === 3) { r = 60; g = 0; b = 80; br = 80; } else if (c === 4) { r = 80; g = 20; b = 0; br = 100; } Shelly.call("PLUGS_UI.SetConfig", { id: 0, config: { leds: { mode: "switch", colors: { "switch:0": { on: { rgb: [r, g, b], brightness: br }, off: { rgb: [r, g, b], brightness: br } } } } } }); } function applyDecision(d) { last_reason = d.reason; st_quality = d.qual; st_max_run_min = d.maxRun; st_gov_state = d.gov; if (d.run) { fan_run_sec += SCHALTZEIT_SEC; if (d.maxRun < 9999 && fan_run_sec >= d.maxRun * 60) { d.run = false; last_reason = "BURST_LIMIT_REACHED"; fan_pause_sec = MIN_PAUSE_MIN * 60; fan_run_sec = 0; } } else { fan_run_sec = 0; if (fan_pause_sec > 0) { fan_pause_sec -= SCHALTZEIT_SEC; if (fan_pause_sec < 0) fan_pause_sec = 0; } } if (d.run !== relay_on) { print("Relais:", d.run ? "EIN" : "AUS", "|", last_reason, "| MaxRun:", st_max_run_min, "min | Qual:", st_quality); Shelly.call("Switch.Set", { id: 0, on: d.run }); relay_on = d.run; } var c = 5; if (bi < BATT_LIMIT || bo < BATT_LIMIT) c = 1; else if (age_in > LOST_LIMIT || age_out > LOST_LIMIT) c = 2; else if (fan_pause_sec > 0) c = 3; else if (d.run) c = 4; updateLeds(c); var isWinter = (typeof ti === "number") ? (ti < WINTER_IN_TH) : false; var mt = calcMoldThresh(ti); var mc = (typeof ti === "number" && typeof hi === "number" && typeof mt === "number" && hi >= mt) ? 1 : 0; st_run = d.run ? 1 : 0; st_target = isWinter ? TARGET_WINTER : TARGET_SUMMER; st_mold_th = (typeof mt === "number") ? mt : 70.0; st_mold_crit = mc; st_spread = (typeof di === "number" && typeof do_ === "number") ? (di - do_) : 0; } Timer.set(SCHALTZEIT_SEC * 1000, true, function () { age_in += SCHALTZEIT_SEC; age_out += SCHALTZEIT_SEC; var d = evaluateDecision(); print("Taupi:", d.run, d.reason, "| Qual:", d.qual, "| Spread:", st_spread); applyDecision(d); }); function decodeBTHome(buf, isOut) { if (typeof buf !== "string" || buf.length < 2) return; var dib = buf.at(0); if ((dib >> 5) !== 2 || (dib & 0x1)) return; var off = 1, pid = -1, t = undefined, h = undefined, b = undefined; while (off < buf.length) { var id = buf.at(off++); if (id === 0x00) { pid = buf.at(off++); } else if (id === 0x01) { b = buf.at(off++); } else if (id === 0x2e) { h = buf.at(off++); } else if (id === 0x1e || id === 0x20 || id === 0x21 || id === 0x2d || id === 0x3a || id === 0x60) { off++; } else if (id === 0x02) { var u = (buf.at(off + 1) << 8) | buf.at(off); off += 2; var raw = (u & 0x8000) ? (u - 0x10000) : u; t = raw * 0.01; } else if (id === 0x03) { var u = (buf.at(off + 1) << 8) | buf.at(off); off += 2; h = u * 0.01; } else if (id === 0x45) { var u = (buf.at(off + 1) << 8) | buf.at(off); off += 2; var raw = (u & 0x8000) ? (u - 0x10000) : u; t = raw * 0.1; } else if (id === 0x08 || id === 0x0c || id === 0x3c || id === 0x3f || id === 0x44 || id === 0x46 || id === 0x5e || id === 0x5f || id === 0xf0) { off += 2; } else if (id === 0x04 || id === 0x05 || id === 0xf2) { off += 3; } else if (id === 0xf1) { off += 4; } else { break; } } if (isOut) { if (pid !== -1) { if (pid === pid_out && typeof t !== "number") return; pid_out = pid; } if (typeof t === "number") to = t; if (typeof h === "number") ho = h; if (typeof to === "number" && typeof ho === "number") do_ = calcDewPoint(to, ho); if (typeof b === "number") bo = b; age_out = 0; } else { if (pid !== -1) { if (pid === pid_in && typeof t !== "number") return; pid_in = pid; } if (typeof t === "number") ti = t; if (typeof h === "number") hi = h; if (typeof ti === "number" && typeof hi === "number") di = calcDewPoint(ti, hi); if (typeof b === "number") bi = b; age_in = 0; } } function BLEScanCallback(event, result) { if (event !== BLE.Scanner.SCAN_RESULT || !result || typeof result.addr !== "string") return; var a = result.addr.toLowerCase(); var isOut = (a === MAC_OUT), isIn = (a === MAC_IN); if (!isOut && !isIn) return; if (!result.service_data || typeof result.service_data["fcd2"] === "undefined") return; decodeBTHome(result.service_data["fcd2"], isOut); } function initBLE() { var c = Shelly.getComponentConfig("ble"); if (!c || !c.enable) return; if (!BLE.Scanner.isRunning()) { BLE.Scanner.Start({ duration_ms: BLE.Scanner.INFINITE_SCAN, active: false }); } BLE.Scanner.Subscribe(BLEScanCallback); } initBLE(); function g(n, v) { return (typeof v === "number" && !isNaN(v)) ? (n + " " + JSON.stringify(v) + "\n") : ""; } if (typeof HTTPServer !== "undefined" && typeof HTTPServer.registerEndpoint === "function") { HTTPServer.registerEndpoint("prometheus", function (req, res) { res.code = 200; res.headers = [["Content-Type", "text/plain; version=0.0.4"]]; res.body = g("taupi_temperature_celsius_innen", ti) + g("taupi_temperature_celsius_aussen", to) + g("taupi_humidity_percent_innen", hi) + g("taupi_humidity_percent_aussen", ho) + g("taupi_dewpoint_celsius_innen", di) + g("taupi_dewpoint_celsius_aussen", do_) + g("taupi_dewpoint_spread_celsius", st_spread) + g("taupi_target_humidity_percent", st_target) + g("taupi_critical_humidity_threshold_percent", st_mold_th) + g("taupi_is_critical", st_mold_crit) + g("taupi_would_fan_activate", st_run) + g("taupi_quality_score", st_quality) + g("taupi_max_allowed_runtime_minutes", st_max_run_min) + g("taupi_thermal_governor_state", st_gov_state) + g("taupi_fan_running_seconds", fan_run_sec) + g("taupi_fan_pause_remaining_seconds", fan_pause_sec) + g("taupi_battery_percent_innen", bi) + g("taupi_battery_percent_aussen", bo) + g("taupi_lost_connection_seconds_innen", age_in) + g("taupi_lost_connection_seconds_aussen", age_out) + g("taupi_relay_status", relay_on ? 1 : 0); res.send(); }); }