////////////// TAUPI 4.0 @ Shelly ////////////// // copyright by boeserbob und holzachr // Fragen an quirb@web.de // Dokumentation und aktuelle Versionen unter https://github.com/BoeserBob/Taupi-4.0 // // Dieses Skript verwandelt z.B. eine Shelly Plug in eine Taupunktlüftersteuerung. // Der Skript schaltet einen angeschlossenen Lüfter über den Schalter des Shellys auf dem er installiert ist entsprechend der Taupunktunterschiede innen - außen. // - Es empfängt Messwert-Events von BLE-Sensoren auf. // - Wenn die Messwerte von den angegebenen Innen- und Außen-Sensoren stammen, werden aus Temperatur und Luftfeuchte die jeweiligen Taupunkte berechnet. // - Eine Timerschleife überprüft regelmäßig, ob alle Einschaltbedingungen fuer den Lüfter erfüllt sind: // - Wenn der Taupunkt innen größer als der Taupunkt außen + einem Schwellwert ist wird der Lüfter eingeschaltet, sonst ausgeschaltet. // - Wenn die Innentermperatur unter 10 °C und die Innenraumfeuchte unter 50 % ist wird der Lüfer ausgeschaltet. //========== Sensor-Konfiguration ========== var sensor_aussen = "${sensor_aussen_mac}"; var sensor_innen = "${sensor_innen_mac}"; //========== Debug-Konfiguration ========== var DEBUG = false; //========== Schalt-Konfiguration ========== var taupunktschwelle = ${taupunktschwelle}; // [°C] Lüfter einschalten wenn TPinnen > (TPaussen + taupunktschwelle)... var mindesttemperatur = ${mindesttemperatur}; // [°C] ...und Tinnen > mindesttemperatur... var mindesthumi = ${mindesthumi}; // [%] ...und RHinnen > mindesthumi var schaltzeit = ${schaltzeit}; // [s] Schaltbedingung prüfen alle X Sekunden var battery_warngrenze = ${battery_warngrenze}; // [%] wenn dieser Schwellwert unterschritten ist blinkt der Plug rot var lost_connection = ${lost_connection}; // [s] Zeit nach der frische Sensordaten gekommen sein müssen um tote Verbindungen zu finden //========== Kritische Feuchte-Konfiguration (Schimmelgefahr) ========== var critical_humi_base = ${critical_humi_base}; // [%] Kritische relative Feuchte bei Referenztemp. var critical_humi_temp_coeff = ${critical_humi_temp_coeff}; // [%/°C] Steigung des Grenzwerts pro °C var critical_humi_ref_temp = ${critical_humi_ref_temp}; // [°C] Referenztemperatur var critical_humi_buffer = ${critical_humi_buffer}; // [%] Sicherheitspuffer //===== Ende Sensor-Konfiguration === AB HIER MUSS NICHTS MEHR GEÄNDERT WERDEN ===================================== var taupunkt_aussen; var taupunkt_innen; var temperatur_innen; var temperatur_aussen; var humidity_innen; var humidity_aussen; var battery_innen; var battery_aussen; var lost_connection_innen; var lost_connection_aussen; var luefterstatus = null; // Merkt sich letzten Schaltzustand, um unnötige Schaltvorgänge zu vermeiden // Taupunktberechnung function taupunkt(T, RH) { var a = (T >= 0) ? 17.27 : 21.875; var b = (T >= 0) ? 237.7 : 265.5; var alpha = (a * T) / (b + T) + Math.log(RH / 100); return (b * alpha) / (a - alpha); } // Lüftersteuerung function schalten() { // Sicherheitsprüfung: Sind alle benötigten Werte vorhanden? if (typeof taupunkt_innen === "undefined" || typeof taupunkt_aussen === "undefined" || typeof temperatur_innen === "undefined" || typeof humidity_innen === "undefined") { print("Nicht alle Sensorwerte vorhanden - Schaltung übersprungen."); farbring(80,80,0,100); return; } // Kritische Feuchte (Schimmelgrenzkurve) berechnen var rh_crit = critical_humi_base - critical_humi_temp_coeff * (temperatur_innen - critical_humi_ref_temp); var rh_activation = rh_crit - critical_humi_buffer; var is_critical = humidity_innen >= rh_activation; print("Schimmelprüfung: T_innen =", temperatur_innen, "°C, RH_innen =", humidity_innen, "%, RH_crit =", rh_crit, "%, Aktivierung ab =", rh_activation, "%, Kritisch =", is_critical); // Sicherheitsprüfung kommen regelmäßig frische Daten von den Sensoren? lost_connection_innen = lost_connection_innen + schaltzeit lost_connection_aussen = lost_connection_aussen + schaltzeit print("letzte Verbindung zum Sensor innen vor " ,lost_connection_innen, " Sekunden " ); print("letzte Verbindung zum Sensor außen vor " ,lost_connection_aussen, " Sekunden " ); if (lost_connection_innen > lost_connection || lost_connection_aussen > lost_connection ) { print("Verbindung zu Sensoren zu lange verloren, Lüfter ausschalten."); Shelly.call("Switch.Set", { id: 0, on: false }); farbring(80,80,0,100); return; } // Visualisierung Batteriefüllstand durch roten Blink if (battery_innen < battery_warngrenze || battery_aussen < battery_warngrenze) { print("Batteriestand niedrig"); farbring(100,0,0,100); } // Schaltlogik (immer schalten, der Shelly schaltet nur, wenn er schaltet muss). if ( is_critical && temperatur_innen > mindesttemperatur && humidity_innen > mindesthumi && taupunkt_innen > taupunkt_aussen + taupunktschwelle ) { print("Lüfter einschalten"); Shelly.call("Switch.Set", { id: 0, on: true }); farbring(80,10,0,100); } else { print("Lüfter ausschalten."); Shelly.call("Switch.Set", { id: 0, on: false }); farbring(0,0,80,100); } } // Farbe Farbring setzen für Standalone Betrieb. function farbring(red,green,blue,helligkeit) { Shelly.call( "PLUGS_UI.SetConfig",{ id:0, config:{"leds":{"mode":"switch","colors": {"switch:0": {"on":{"rgb":[red,green,blue],"brightness":helligkeit}, "off":{"rgb":[red,green,blue],"brightness":helligkeit}}}}}}, function (result, code, msg, ud) { }, null ); } // Event-Verarbeitung function checkBlu(event) { var addr = event.address.toLowerCase(); var aussen = sensor_aussen.toLowerCase(); var innen = sensor_innen.toLowerCase(); if (DEBUG) { print("DEBUG checkBlu: event.addr=", addr, " aussen=", aussen, " innen=", innen); } if (addr === aussen) { temperatur_aussen = event.temperature; humidity_aussen = event.humidity; taupunkt_aussen = taupunkt(event.temperature, event.humidity); battery_aussen = event.battery; lost_connection_aussen = 0; print("Neue Werte für Außen:", temperatur_aussen, "°C,", humidity_aussen, "%, Tp:", taupunkt_aussen, "°C, Batt: ", battery_aussen, " % "); } else if (addr === innen) { temperatur_innen = event.temperature; humidity_innen = event.humidity; taupunkt_innen = taupunkt(event.temperature, event.humidity); battery_innen = event.battery; lost_connection_innen = 0; print("Neue Werte für Innen:", temperatur_innen, "°C,", humidity_innen, "%, Tp:", taupunkt_innen, "°C, Batt: " , battery_innen, " % "); } } // Haupt-Timer für Steuerlogik Timer.set(schaltzeit * 1000, true, function () { var rh_crit_t = (typeof temperatur_innen !== "undefined") ? (critical_humi_base - critical_humi_temp_coeff * (temperatur_innen - critical_humi_ref_temp)) : undefined; var rh_act_t = (typeof rh_crit_t !== "undefined") ? (rh_crit_t - critical_humi_buffer) : undefined; print("----- Steuerung alle", schaltzeit, "s -----"); print("Innen: T =", temperatur_innen, "°C, RH =", humidity_innen, "%, Tp =", taupunkt_innen, "°C, Aktivierung ab RH =", rh_act_t, "% (Batterie:", battery_innen, "%)"); print("Außen: T =", temperatur_aussen, "°C, RH =", humidity_aussen, "%, Tp =", taupunkt_aussen, "°C (Batterie:", battery_aussen, "%)"); schalten(); }); ///////////////// BLE-Decoder /////////////////////// const BTHOME_SVC_ID_STR = "fcd2"; const uint8 = 0; const int8 = 1; const uint16 = 2; const int16 = 3; // The BTH object defines the structure of the BTHome data (trimmed to temperature/humidity/battery) const BTH = { 0x00: { n: "pid", t: uint8 }, 0x01: { n: "battery", t: uint8 }, 0x02: { n: "temperature", t: int16, f: 0.01 }, 0x03: { n: "humidity", t: uint16, f: 0.01 }, 0x2e: { n: "humidity", t: uint8 }, 0x45: { n: "temperature", t: int16, f: 0.1 } }; function getByteSize(type) { if (type === uint8 || type === int8) return 1; if (type === uint16 || type === int16) return 2; return 255; } // Functions for decoding and unpacking the service data from Shelly BLU devices const BTHomeDecoder = { utoi: function (num, bitsz) { const mask = 1 << (bitsz - 1); return num & mask ? num - (1 << bitsz) : num; }, getUInt8: function (buffer) { return buffer.at(0); }, getInt8: function (buffer) { return this.utoi(this.getUInt8(buffer), 8); }, getUInt16LE: function (buffer) { return 0xffff & ((buffer.at(1) << 8) | buffer.at(0)); }, getInt16LE: function (buffer) { return this.utoi(this.getUInt16LE(buffer), 16); }, getBufValue: function (type, buffer) { if (buffer.length < getByteSize(type)) return null; if (type === uint8) return this.getUInt8(buffer); if (type === int8) return this.getInt8(buffer); if (type === uint16) return this.getUInt16LE(buffer); if (type === int16) return this.getInt16LE(buffer); return null; }, unpack: function (buffer) { if (typeof buffer !== "string" || buffer.length === 0) return null; let result = {}; let _dib = buffer.at(0); result["encryption"] = _dib & 0x1 ? true : false; result["BTHome_version"] = _dib >> 5; if (result["BTHome_version"] !== 2) return null; if (result["encryption"]) return result; buffer = buffer.slice(1); let _bth; let _value; while (buffer.length > 0) { _bth = BTH[buffer.at(0)]; if (typeof _bth === "undefined") { break; } buffer = buffer.slice(1); _value = this.getBufValue(_bth.t, buffer); if (_value === null) break; if (typeof _bth.f !== "undefined") _value = _value * _bth.f; if (typeof result[_bth.n] === "undefined") { result[_bth.n] = _value; } else { if (Array.isArray(result[_bth.n])) { result[_bth.n].push(_value); } else { result[_bth.n] = [ result[_bth.n], _value ]; } } buffer = buffer.slice(getByteSize(_bth.t)); } return result; }, }; let lastPacketId = 0x100; function BLEScanCallback(event, result) { if (event !== BLE.Scanner.SCAN_RESULT) { return; } if (DEBUG) { print("DEBUG BLEScanCallback: addr=", result.addr, " rssi=", result.rssi); } if (typeof result.service_data === "undefined" || typeof result.service_data[BTHOME_SVC_ID_STR] === "undefined") { return; } let unpackedData = BTHomeDecoder.unpack(result.service_data[BTHOME_SVC_ID_STR]); if (unpackedData === null || typeof unpackedData === "undefined" || unpackedData["encryption"]) { print("Error: Encrypted devices are not supported or unpacking failed"); return; } if (DEBUG) { print("DEBUG unpacked BTHome:", JSON.stringify(unpackedData)); } if (lastPacketId === unpackedData.pid) { return; } lastPacketId = unpackedData.pid; unpackedData.address = result.addr; checkBlu(unpackedData); } function initBLE() { const BLEConfig = Shelly.getComponentConfig("ble"); if (!BLEConfig.enable) { print("Error: The Bluetooth is not enabled, please enable it from settings"); return; } if (BLE.Scanner.isRunning()) { print("Info: The BLE gateway is running, the BLE scan configuration is managed by the device"); } else { const bleScanner = BLE.Scanner.Start({ duration_ms: BLE.Scanner.INFINITE_SCAN, active: false }); if(!bleScanner) { print("Error: Can not start new scanner"); } } BLE.Scanner.Subscribe(BLEScanCallback); } initBLE(); // Expose metrics for Prometheus if (typeof HTTPServer !== "undefined" && typeof HTTPServer.registerEndpoint === "function") { HTTPServer.registerEndpoint("prometheus", function(req, res) { var body = ""; function addGauge(name, val, help) { if ((typeof val === "number" && !isNaN(val)) || typeof val === "boolean") { var numVal = (typeof val === "boolean") ? (val ? 1 : 0) : val; if (help) { body += "# HELP " + name + " " + help + "\n"; } body += "# TYPE " + name + " gauge\n"; body += name + " " + numVal + "\n"; } } // Calculate helper values var rh_crit = (typeof temperatur_innen !== "undefined" && !isNaN(temperatur_innen)) ? (critical_humi_base - critical_humi_temp_coeff * (temperatur_innen - critical_humi_ref_temp)) : undefined; var rh_activation = (typeof rh_crit !== "undefined" && !isNaN(rh_crit)) ? (rh_crit - critical_humi_buffer) : undefined; var is_critical_val = (typeof humidity_innen !== "undefined" && !isNaN(humidity_innen) && typeof rh_activation !== "undefined" && !isNaN(rh_activation)) ? (humidity_innen >= rh_activation) : undefined; var would_fan_activate = false; if (typeof is_critical_val !== "undefined" && is_critical_val !== null && typeof temperatur_innen !== "undefined" && !isNaN(temperatur_innen) && typeof humidity_innen !== "undefined" && !isNaN(humidity_innen) && typeof taupunkt_innen !== "undefined" && !isNaN(taupunkt_innen) && typeof taupunkt_aussen !== "undefined" && !isNaN(taupunkt_aussen)) { would_fan_activate = is_critical_val && (temperatur_innen > mindesttemperatur) && (humidity_innen > mindesthumi) && (taupunkt_innen > taupunkt_aussen + taupunktschwelle); } addGauge("taupi_temperature_celsius_innen", temperatur_innen, "Indoor temperature in Celsius"); addGauge("taupi_temperature_celsius_aussen", temperatur_aussen, "Outdoor temperature in Celsius"); addGauge("taupi_humidity_percent_innen", humidity_innen, "Indoor relative humidity in percent"); addGauge("taupi_humidity_percent_aussen", humidity_aussen, "Outdoor relative humidity in percent"); addGauge("taupi_dewpoint_celsius_innen", taupunkt_innen, "Indoor dew point in Celsius"); addGauge("taupi_dewpoint_celsius_aussen", taupunkt_aussen, "Outdoor dew point in Celsius"); addGauge("taupi_battery_percent_innen", battery_innen, "Indoor sensor battery percentage"); addGauge("taupi_battery_percent_aussen", battery_aussen, "Outdoor sensor battery percentage"); addGauge("taupi_lost_connection_seconds_innen", lost_connection_innen, "Seconds since last contact with indoor sensor"); addGauge("taupi_lost_connection_seconds_aussen", lost_connection_aussen, "Seconds since last contact with outdoor sensor"); addGauge("taupi_critical_humidity_threshold_percent", rh_activation, "Calculated relative humidity threshold above which it is critical"); addGauge("taupi_is_critical", is_critical_val, "Flag indicating mold danger inside (humidity exceeds threshold)"); addGauge("taupi_would_fan_activate", would_fan_activate, "Flag indicating if all fan activation criteria are met"); var relay_status = Shelly.getComponentStatus("switch", 0); if (relay_status && typeof relay_status.output !== "undefined") { addGauge("taupi_relay_status", relay_status.output, "Physical switch status (1 = on, 0 = off)"); } res.body = body; res.headers = [["Content-Type", "text/plain; version=0.0.4"]]; res.code = 200; res.send(); }); }