// ============================================================================ // Viesta / Hantech / TCL Splitklima ESP32-S3 Gateway v2.0 FINAL // Arduino-only Firmware, KEIN ESPHome // // Funktionen: // - WLAN Auto-Reconnect + Fallback AP // - Arduino OTA // - Webserver mit Bedienung und Diagnose // - MQTT + Home Assistant MQTT Climate Discovery // - Lokale UART-Steuerung der Klima ueber TCL-Protokoll // - Power / Mode / Temperatur / Luefter / Swing / Presets // - Zyklischer State-Publish, damit Home Assistant die Steuerung nicht verliert // // Anschluss Klima -> ESP32-S3: // Klima 5V -> ESP 5V/VIN // Klima GND -> ESP GND // Klima TX -> ESP GPIO16 RX // Klima RX -> ESP GPIO17 TX // ============================================================================ #include #include #include #include #include #include #include "esp_system.h" #define FW_VERSION "2.8.2-mobile-compact-buttons" // ============================================================================ // WLAN / MQTT / OTA // ============================================================================ const char* WIFI_SSID = "xxxxxxxxxx"; const char* WIFI_PASS = "xxxxxxxxxxx"; const char* MQTT_HOST = "192.168.178.xx"; const int MQTT_PORT = 1883; const char* MQTT_USER = "admin"; const char* MQTT_PASS = "admin"; const char* OTA_HOST = "viesta-klima"; const char* OTA_PASS = "viesta-ota"; const char* AP_SSID = "Viesta-Klima-Setup"; const char* AP_PASS = "viesta-setup-123"; const char* DEVICE_ID = "viesta_klima_gateway"; const char* DEVICE_NAME = "Viesta Klima Gateway"; const char* MQTT_BASE = "viesta/klima"; const char* HA_DISCOVERY_PREFIX = "homeassistant"; // ============================================================================ // Shelly Verbrauchsmessung // ============================================================================ // IP frei konfigurierbar im Webinterface. // Standard: Shelly an der Klima Wohnzimmer String shellyHost = "192.168.178.79"; float shellyPowerW = NAN; float shellyVoltageV = NAN; float shellyCurrentA = NAN; bool shellyOnline = false; String shellyLastError = "-"; unsigned long shellyOkCount = 0; unsigned long shellyErrCount = 0; unsigned long lastShellyPollMs = 0; const unsigned long SHELLY_POLL_MS = 5000; // ============================================================================ // UART Klima // ============================================================================ HardwareSerial klimaSerial(2); static const int KLIMA_RX_PIN = 16; static const int KLIMA_TX_PIN = 17; static const uint32_t KLIMA_BAUD = 9600; static const uint32_t KLIMA_CONFIG = SERIAL_8E1; // TCL Poll: Status abfragen uint8_t TCL_POLL[8] = {0xBB, 0x00, 0x01, 0x04, 0x02, 0x01, 0x00, 0xBD}; // RX Parser uint8_t rxBuf[80]; uint8_t rxPos = 0; uint8_t expectedLen = 0; unsigned long lastRxMs = 0; // ============================================================================ // Services // ============================================================================ WiFiClient espClient; PubSubClient mqtt(espClient); WebServer server(80); Preferences prefs; // ============================================================================ // Klima State // ============================================================================ String acMode = "off"; // off, auto, cool, heat, dry, fan_only String fanMode = "auto"; // auto, low, medium, middle, high, quiet, focus, diffuse String swingMode = "off"; // off, vertical, horizontal, both String presetMode = "none"; // none, eco, sleep, comfort float targetTemp = 24.0; float currentTemp = NAN; bool acOnline = false; bool beepEnabled = true; bool displayEnabled = true; // Diagnose unsigned long rxBytes = 0; unsigned long txBytes = 0; unsigned long rxFrames = 0; unsigned long txFrames = 0; unsigned long checksumErrors = 0; unsigned long wrongBytes = 0; unsigned long mqttReconnects = 0; unsigned long wifiReconnects = 0; String lastRxFrame = "-"; String lastTxFrame = "-"; String lastCommand = "-"; String lastError = "-"; String uartLog = ""; const size_t UART_LOG_MAX = 16000; // Timings unsigned long lastPollMs = 0; unsigned long lastStatePublishMs = 0; unsigned long lastWifiCheckMs = 0; unsigned long lastMqttCheckMs = 0; unsigned long lastDiscoveryMs = 0; unsigned long bootMs = 0; const unsigned long POLL_INTERVAL_MS = 5000; const unsigned long STATE_PUBLISH_MS = 10000; const unsigned long WIFI_RETRY_MS = 15000; const unsigned long MQTT_RETRY_MS = 5000; const unsigned long DISCOVERY_REFRESH_MS = 300000; // ============================================================================ // Helpers // ============================================================================ String htmlEscape(String s) { s.replace("&", "&"); s.replace("<", "<"); s.replace(">", ">"); s.replace("\"", """); return s; } String jsonEscape(String s) { s.replace("\\", "\\\\"); s.replace("\"", "\\\""); s.replace("\r", ""); s.replace("\n", "\\n"); return s; } String twoDigits(unsigned long v) { return v < 10 ? "0" + String(v) : String(v); } String uptimeText() { unsigned long sec = millis() / 1000; unsigned long d = sec / 86400; sec %= 86400; unsigned long h = sec / 3600; sec %= 3600; unsigned long m = sec / 60; sec %= 60; return String(d) + "d " + twoDigits(h) + ":" + twoDigits(m) + ":" + twoDigits(sec); } String byteHex(uint8_t b) { char buf[4]; snprintf(buf, sizeof(buf), "%02X", b); return String(buf); } String bytesToHex(const uint8_t* data, size_t len) { String out; out.reserve(len * 3); for (size_t i = 0; i < len; i++) { if (i) out += " "; out += byteHex(data[i]); } return out; } void appendLog(const String& line) { uartLog += line + "\n"; if (uartLog.length() > UART_LOG_MAX) { uartLog = uartLog.substring(uartLog.length() - UART_LOG_MAX); } } uint8_t tclChecksum(const uint8_t* data, size_t len) { uint8_t crc = 0; for (size_t i = 0; i < len - 1; i++) crc ^= data[i]; return crc; } String ipText() { if (WiFi.status() == WL_CONNECTED) return WiFi.localIP().toString(); if (WiFi.getMode() & WIFI_AP) return WiFi.softAPIP().toString(); return "-"; } String rssiText() { if (WiFi.status() != WL_CONNECTED) return "-"; return String(WiFi.RSSI()) + " dBm"; } float chipTempC() { #if defined(ARDUINO_ARCH_ESP32) return temperatureRead(); #else return NAN; #endif } void saveState() { prefs.putString("mode", acMode); prefs.putString("fan", fanMode); prefs.putString("swing", swingMode); prefs.putString("preset", presetMode); prefs.putFloat("target", targetTemp); prefs.putString("shelly", shellyHost); } void loadState() { acMode = prefs.getString("mode", "off"); fanMode = prefs.getString("fan", "auto"); swingMode = prefs.getString("swing", "off"); presetMode = prefs.getString("preset", "none"); targetTemp = prefs.getFloat("target", 24.0); shellyHost = prefs.getString("shelly", "192.168.178.79"); if (isnan(targetTemp) || targetTemp < 16 || targetTemp > 31) targetTemp = 24.0; } // ============================================================================ // TCL Klima Protokoll // Abgeleitet aus tclac: Header BB 00 01 03 20..., XOR Checksumme. // ============================================================================ void sendPoll() { klimaSerial.write(TCL_POLL, sizeof(TCL_POLL)); txBytes += sizeof(TCL_POLL); txFrames++; lastTxFrame = bytesToHex(TCL_POLL, sizeof(TCL_POLL)); } uint8_t modeCode(const String& m) { if (m == "heat") return 0x01; if (m == "dry") return 0x02; if (m == "cool") return 0x03; if (m == "fan_only") return 0x07; if (m == "auto") return 0x08; return 0x08; } void sendControl() { if (targetTemp < 16) targetTemp = 16; if (targetTemp > 31) targetTemp = 31; uint8_t data[38]; memset(data, 0, sizeof(data)); data[0] = 0xBB; data[1] = 0x00; data[2] = 0x01; data[3] = 0x03; // control data[4] = 0x20; // length data[5] = 0x03; data[6] = 0x01; // Byte 7: eco/display/beep/power data[7] = 0x00; if (beepEnabled) data[7] |= 0x20; if (displayEnabled && acMode != "off") data[7] |= 0x40; if (presetMode == "eco") data[7] |= 0x80; // Power + mode if (acMode == "off") { data[8] = 0x00; } else { data[7] |= 0x04; // power on data[8] |= modeCode(acMode); // heat/dry/cool/fan/auto } // Fan special bits if (fanMode == "quiet") { data[8] |= 0x80; } else if (fanMode == "diffuse") { data[8] |= 0x40; } if (presetMode == "comfort") data[8] |= 0x10; // Temperatur: TCL = 31 - Solltemperatur data[9] = (uint8_t)(31 - (int)round(targetTemp)); // Fan + vertical swing data[10] = 0x00; if (fanMode == "low") data[10] |= 0x01; else if (fanMode == "medium") data[10] |= 0x03; else if (fanMode == "middle") data[10] |= 0x06; else if (fanMode == "high") data[10] |= 0x07; else if (fanMode == "focus") data[10] |= 0x05; // auto/quiet/diffuse bleiben 0 im unteren Fan-Nibble if (swingMode == "vertical" || swingMode == "both") data[10] |= 0x38; // Horizontal swing data[11] = 0x00; if (swingMode == "horizontal" || swingMode == "both") data[11] |= 0x08; data[12] = 0x00; data[13] = 0x01; data[14] = 0x00; data[15] = 0x00; data[16] = 0x00; data[17] = 0x00; data[18] = 0x00; data[19] = (presetMode == "sleep") ? 0x01 : 0x00; // 20-31 bleiben 0 // Vertikale Lamellenposition/Swing-Richtung Standard: up/down // Wenn Swing aus, bleibt letzte Position. data[32] = 0x00; if (swingMode == "vertical" || swingMode == "both") data[32] |= 0x08; // Horizontale Lamellenposition/Swing-Richtung Standard: left/right data[33] = 0x00; if (swingMode == "horizontal" || swingMode == "both") data[33] |= 0x08; data[34] = 0x00; data[35] = 0x00; data[36] = 0x00; data[37] = tclChecksum(data, sizeof(data)); klimaSerial.write(data, sizeof(data)); txBytes += sizeof(data); txFrames++; lastTxFrame = bytesToHex(data, sizeof(data)); appendLog("TX " + lastTxFrame); saveState(); } void parseTclFrame(const uint8_t* d, size_t len) { if (len < 61) return; // Current temp nach tclac: (((raw / 374) - 32) / 1.8) uint16_t rawTemp = ((uint16_t)d[17] << 8) | d[18]; float ct = (((float)rawTemp / 374.0f) - 32.0f) / 1.8f; if (ct > -20 && ct < 80) currentTemp = ct; float tt = (float)((d[8] & 0x0F) + 16); if (tt >= 16 && tt <= 31) targetTemp = tt; bool powerOn = d[7] & 0x10; if (!powerOn) { acMode = "off"; presetMode = "none"; } else { uint8_t m = d[7] & 0x3F; if (m == 0x35) acMode = "auto"; else if (m == 0x31) acMode = "cool"; else if (m == 0x33) acMode = "dry"; else if (m == 0x32) acMode = "fan_only"; else if (m == 0x34) acMode = "heat"; uint8_t f = d[8] & 0xF0; if (d[33] & 0x80) fanMode = "quiet"; else if (d[7] & 0x80) fanMode = "diffuse"; else if (f == 0x80) fanMode = "auto"; else if (f == 0x90) fanMode = "low"; else if (f == 0xA0) fanMode = "medium"; else if (f == 0xC0) fanMode = "middle"; else if (f == 0xD0) fanMode = "high"; else if (f == 0xB0) fanMode = "focus"; uint8_t sw = d[10] & 0x60; if (sw == 0x00) swingMode = "off"; else if (sw == 0x20) swingMode = "horizontal"; else if (sw == 0x40) swingMode = "vertical"; else if (sw == 0x60) swingMode = "both"; presetMode = "none"; if (d[7] & 0x40) presetMode = "eco"; else if (d[9] & 0x04) presetMode = "comfort"; else if (d[19] & 0x01) presetMode = "sleep"; } acOnline = true; publishState(); } void processUart() { while (klimaSerial.available()) { uint8_t b = klimaSerial.read(); rxBytes++; lastRxMs = millis(); if (rxPos == 0) { if (b != 0xBB) { wrongBytes++; continue; } rxBuf[rxPos++] = b; expectedLen = 0; continue; } rxBuf[rxPos++] = b; if (rxPos == 5) { expectedLen = rxBuf[4] + 6; // Header 5 + payload length + checksum if (expectedLen > sizeof(rxBuf) || expectedLen < 8) { rxPos = 0; expectedLen = 0; checksumErrors++; } } if (expectedLen > 0 && rxPos >= expectedLen) { uint8_t crc = tclChecksum(rxBuf, expectedLen); lastRxFrame = bytesToHex(rxBuf, expectedLen); appendLog("RX " + lastRxFrame); rxFrames++; if (crc == rxBuf[expectedLen - 1]) { parseTclFrame(rxBuf, expectedLen); } else { checksumErrors++; lastError = "Checksum Fehler: erwartet " + byteHex(crc) + " bekommen " + byteHex(rxBuf[expectedLen - 1]); } rxPos = 0; expectedLen = 0; } if (rxPos >= sizeof(rxBuf)) { rxPos = 0; expectedLen = 0; } } // Timeout fuer halbe Frames if (rxPos > 0 && millis() - lastRxMs > 200) { rxPos = 0; expectedLen = 0; } } // ============================================================================ // Shelly HTTP Direktabfrage // ============================================================================ String normalizeShellyHost(String v) { v.trim(); v.replace("http://", ""); v.replace("https://", ""); int slash = v.indexOf('/'); if (slash >= 0) v = v.substring(0, slash); return v; } float jsonFloatValue(const String& body, const String& key, float fallback = NAN) { String needle = "\"" + key + "\":"; int p = body.indexOf(needle); if (p < 0) return fallback; p += needle.length(); while (p < (int)body.length() && body[p] == ' ') p++; int e = p; while (e < (int)body.length()) { char c = body[e]; if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E') { e++; } else { break; } } if (e <= p) return fallback; return body.substring(p, e).toFloat(); } void pollShelly() { if (WiFi.status() != WL_CONNECTED) return; String host = normalizeShellyHost(shellyHost); if (host.length() < 7) { shellyOnline = false; shellyLastError = "Shelly IP fehlt"; return; } HTTPClient http; String url = "http://" + host + "/rpc/Switch.GetStatus?id=0"; http.setConnectTimeout(1200); http.setTimeout(1800); if (!http.begin(url)) { shellyOnline = false; shellyErrCount++; shellyLastError = "HTTP begin fehlgeschlagen"; return; } int code = http.GET(); if (code == 200) { String body = http.getString(); float p = jsonFloatValue(body, "apower"); float v = jsonFloatValue(body, "voltage"); float c = jsonFloatValue(body, "current"); if (!isnan(p)) shellyPowerW = p; if (!isnan(v)) shellyVoltageV = v; if (!isnan(c)) shellyCurrentA = c; shellyOnline = true; shellyOkCount++; shellyLastError = "-"; } else { shellyOnline = false; shellyErrCount++; shellyLastError = "HTTP " + String(code); } http.end(); } // ============================================================================ // MQTT / Home Assistant Discovery // ============================================================================ String topic(const String& sub) { return String(MQTT_BASE) + "/" + sub; } void mqttPublish(const String& t, const String& payload, bool retained = true) { if (mqtt.connected()) mqtt.publish(t.c_str(), payload.c_str(), retained); } void publishState() { if (!mqtt.connected()) return; mqttPublish(topic("state/mode"), acMode); mqttPublish(topic("state/target_temperature"), String(targetTemp, 0)); mqttPublish(topic("state/current_temperature"), isnan(currentTemp) ? String("") : String(currentTemp, 1)); mqttPublish(topic("state/fan_mode"), fanMode); mqttPublish(topic("state/swing_mode"), swingMode); mqttPublish(topic("state/preset_mode"), presetMode); mqttPublish(topic("state/online"), acOnline ? "online" : "offline"); mqttPublish(topic("diag/ip"), ipText()); mqttPublish(topic("diag/rssi"), String(WiFi.RSSI())); mqttPublish(topic("diag/uptime"), String(millis() / 1000)); mqttPublish(topic("diag/free_heap"), String(ESP.getFreeHeap())); mqttPublish(topic("diag/chip_temp"), String(chipTempC(), 1)); mqttPublish(topic("diag/rx_bytes"), String(rxBytes)); mqttPublish(topic("diag/tx_bytes"), String(txBytes)); mqttPublish(topic("diag/rx_frames"), String(rxFrames)); mqttPublish(topic("diag/tx_frames"), String(txFrames)); mqttPublish(topic("diag/checksum_errors"), String(checksumErrors)); mqttPublish(topic("diag/wrong_bytes"), String(wrongBytes)); mqttPublish(topic("diag/last_error"), lastError); mqttPublish(topic("diag/firmware"), FW_VERSION); mqttPublish(topic("diag/last_command"), lastCommand); mqttPublish(topic("diag/wifi_reconnects"), String(wifiReconnects)); mqttPublish(topic("diag/mqtt_reconnects"), String(mqttReconnects)); mqttPublish(topic("shelly/power"), isnan(shellyPowerW) ? String("") : String(shellyPowerW, 1)); mqttPublish(topic("shelly/voltage"), isnan(shellyVoltageV) ? String("") : String(shellyVoltageV, 1)); mqttPublish(topic("shelly/current"), isnan(shellyCurrentA) ? String("") : String(shellyCurrentA, 3)); mqttPublish(topic("shelly/online"), shellyOnline ? "online" : "offline"); mqttPublish(topic("shelly/host"), shellyHost); mqttPublish(topic("shelly/last_error"), shellyLastError); } String deviceJson() { return String("{\"identifiers\":[\"") + DEVICE_ID + "\"],\"name\":\"" + DEVICE_NAME + "\",\"manufacturer\":\"Viesta/Hantech/TCL OEM\",\"model\":\"ESP32-S3 UART Gateway\",\"sw_version\":\"" + FW_VERSION + "\"}"; } void publishDiscovery() { if (!mqtt.connected()) return; String dev = deviceJson(); String base = MQTT_BASE; String climateConfig = "{"; climateConfig += "\"name\":\"Viesta Klima\","; climateConfig += "\"unique_id\":\"viesta_klima_climate\","; climateConfig += "\"object_id\":\"viesta_klima\","; climateConfig += "\"modes\":[\"off\",\"auto\",\"cool\",\"heat\",\"dry\",\"fan_only\"],"; climateConfig += "\"fan_modes\":[\"auto\",\"quiet\",\"low\",\"medium\",\"middle\",\"high\",\"focus\",\"diffuse\"],"; climateConfig += "\"swing_modes\":[\"off\",\"vertical\",\"horizontal\",\"both\"],"; climateConfig += "\"preset_modes\":[\"none\",\"eco\",\"sleep\",\"comfort\"],"; climateConfig += "\"mode_state_topic\":\"" + base + "/state/mode\","; climateConfig += "\"mode_command_topic\":\"" + base + "/cmd/mode\","; climateConfig += "\"temperature_state_topic\":\"" + base + "/state/target_temperature\","; climateConfig += "\"temperature_command_topic\":\"" + base + "/cmd/target_temperature\","; climateConfig += "\"current_temperature_topic\":\"" + base + "/state/current_temperature\","; climateConfig += "\"fan_mode_state_topic\":\"" + base + "/state/fan_mode\","; climateConfig += "\"fan_mode_command_topic\":\"" + base + "/cmd/fan_mode\","; climateConfig += "\"swing_mode_state_topic\":\"" + base + "/state/swing_mode\","; climateConfig += "\"swing_mode_command_topic\":\"" + base + "/cmd/swing_mode\","; climateConfig += "\"preset_mode_state_topic\":\"" + base + "/state/preset_mode\","; climateConfig += "\"preset_mode_command_topic\":\"" + base + "/cmd/preset_mode\","; climateConfig += "\"min_temp\":16,\"max_temp\":31,\"temp_step\":1,\"precision\":1,"; climateConfig += "\"availability_topic\":\"" + base + "/availability\","; climateConfig += "\"payload_available\":\"online\",\"payload_not_available\":\"offline\","; climateConfig += "\"device\":" + dev; climateConfig += "}"; mqtt.publish((String(HA_DISCOVERY_PREFIX) + "/climate/viesta_klima/config").c_str(), climateConfig.c_str(), true); auto sensorConfig = [&](const String& id, const String& name, const String& st, const String& unit, const String& devClass, const String& stateClass) { String cfg = "{"; cfg += "\"name\":\"" + name + "\","; cfg += "\"unique_id\":\"viesta_klima_" + id + "\","; cfg += "\"object_id\":\"viesta_klima_" + id + "\","; cfg += "\"state_topic\":\"" + base + "/" + st + "\","; if (unit.length()) cfg += "\"unit_of_measurement\":\"" + unit + "\","; if (devClass.length()) cfg += "\"device_class\":\"" + devClass + "\","; if (stateClass.length()) cfg += "\"state_class\":\"" + stateClass + "\","; cfg += "\"availability_topic\":\"" + base + "/availability\","; cfg += "\"device\":" + dev; cfg += "}"; mqtt.publish((String(HA_DISCOVERY_PREFIX) + "/sensor/viesta_klima_" + id + "/config").c_str(), cfg.c_str(), true); }; sensorConfig("esp_temperatur", "Viesta Klima ESP Temperatur", "diag/chip_temp", "°C", "temperature", "measurement"); sensorConfig("wlan_signal", "Viesta Klima WLAN Signal", "diag/rssi", "dBm", "signal_strength", "measurement"); sensorConfig("uptime", "Viesta Klima Uptime Sekunden", "diag/uptime", "s", "duration", "total_increasing"); sensorConfig("free_heap", "Viesta Klima Freier Heap", "diag/free_heap", "B", "", "measurement"); sensorConfig("rx_bytes", "Viesta Klima UART RX Bytes", "diag/rx_bytes", "B", "", "total_increasing"); sensorConfig("tx_bytes", "Viesta Klima UART TX Bytes", "diag/tx_bytes", "B", "", "total_increasing"); sensorConfig("rx_frames", "Viesta Klima UART RX Frames", "diag/rx_frames", "", "", "total_increasing"); sensorConfig("tx_frames", "Viesta Klima UART TX Frames", "diag/tx_frames", "", "", "total_increasing"); sensorConfig("checksum_errors", "Viesta Klima Checksum Fehler", "diag/checksum_errors", "", "", "total_increasing"); sensorConfig("wrong_bytes", "Viesta Klima Wrong Bytes", "diag/wrong_bytes", "", "", "total_increasing"); sensorConfig("wifi_reconnects", "Viesta Klima WLAN Reconnects", "diag/wifi_reconnects", "", "", "total_increasing"); sensorConfig("mqtt_reconnects", "Viesta Klima MQTT Reconnects", "diag/mqtt_reconnects", "", "", "total_increasing"); sensorConfig("shelly_power", "Viesta Klima Shelly Leistung", "shelly/power", "W", "power", "measurement"); sensorConfig("shelly_voltage", "Viesta Klima Shelly Spannung", "shelly/voltage", "V", "voltage", "measurement"); sensorConfig("shelly_current", "Viesta Klima Shelly Strom", "shelly/current", "A", "current", "measurement"); auto textSensorConfig = [&](const String& id, const String& name, const String& st) { String cfg = "{"; cfg += "\"name\":\"" + name + "\","; cfg += "\"unique_id\":\"viesta_klima_" + id + "\","; cfg += "\"object_id\":\"viesta_klima_" + id + "\","; cfg += "\"state_topic\":\"" + base + "/" + st + "\","; cfg += "\"availability_topic\":\"" + base + "/availability\","; cfg += "\"device\":" + dev; cfg += "}"; mqtt.publish((String(HA_DISCOVERY_PREFIX) + "/sensor/viesta_klima_" + id + "/config").c_str(), cfg.c_str(), true); }; textSensorConfig("ip", "Viesta Klima IP", "diag/ip"); textSensorConfig("firmware", "Viesta Klima Firmware", "diag/firmware"); textSensorConfig("last_command", "Viesta Klima Letzter Befehl", "diag/last_command"); textSensorConfig("last_error", "Viesta Klima Letzter Fehler", "diag/last_error"); textSensorConfig("shelly_host", "Viesta Klima Shelly IP", "shelly/host"); textSensorConfig("shelly_online", "Viesta Klima Shelly Status", "shelly/online"); textSensorConfig("shelly_error", "Viesta Klima Shelly Fehler", "shelly/last_error"); String btn = "{\"name\":\"Viesta Klima Neustart\",\"unique_id\":\"viesta_klima_restart\",\"command_topic\":\"" + base + "/cmd/restart\",\"payload_press\":\"restart\",\"device\":" + dev + "}"; mqtt.publish((String(HA_DISCOVERY_PREFIX) + "/button/viesta_klima_restart/config").c_str(), btn.c_str(), true); mqttPublish(topic("availability"), "online", true); publishState(); } void mqttCallback(char* topicC, byte* payload, unsigned int length) { String t = String(topicC); String msg; for (unsigned int i = 0; i < length; i++) msg += (char)payload[i]; msg.trim(); msg.toLowerCase(); bool changed = false; if (t.endsWith("/cmd/mode")) { if (msg == "off" || msg == "auto" || msg == "cool" || msg == "heat" || msg == "dry" || msg == "fan_only") { acMode = msg; changed = true; lastCommand = "MQTT mode=" + msg; } } else if (t.endsWith("/cmd/target_temperature")) { float v = msg.toFloat(); if (v >= 16 && v <= 31) { targetTemp = round(v); changed = true; lastCommand = "MQTT temp=" + String(targetTemp,0); } } else if (t.endsWith("/cmd/fan_mode")) { if (msg == "auto" || msg == "quiet" || msg == "low" || msg == "medium" || msg == "middle" || msg == "high" || msg == "focus" || msg == "diffuse") { fanMode = msg; changed = true; lastCommand = "MQTT fan=" + msg; } } else if (t.endsWith("/cmd/swing_mode")) { if (msg == "off" || msg == "vertical" || msg == "horizontal" || msg == "both") { swingMode = msg; changed = true; lastCommand = "MQTT swing=" + msg; } } else if (t.endsWith("/cmd/preset_mode")) { if (msg == "none" || msg == "eco" || msg == "sleep" || msg == "comfort") { presetMode = msg; changed = true; lastCommand = "MQTT preset=" + msg; } } else if (t.endsWith("/cmd/restart")) { ESP.restart(); } else if (t.endsWith("/cmd/raw")) { // Optional: Raw Hex direkt senden, z.B. BB 00 ... // bewusst minimal gehalten; Web UI ist dafuer komfortabler } if (changed) { sendControl(); publishState(); } } bool connectMqtt() { if (WiFi.status() != WL_CONNECTED) return false; if (mqtt.connected()) return true; mqtt.setServer(MQTT_HOST, MQTT_PORT); mqtt.setCallback(mqttCallback); mqtt.setBufferSize(4096); String clientId = String("viesta-klima-") + String((uint32_t)ESP.getEfuseMac(), HEX); bool ok; if (strlen(MQTT_USER) > 0) { ok = mqtt.connect(clientId.c_str(), MQTT_USER, MQTT_PASS, topic("availability").c_str(), 0, true, "offline"); } else { ok = mqtt.connect(clientId.c_str(), topic("availability").c_str(), 0, true, "offline"); } if (ok) { mqttReconnects++; mqtt.subscribe(topic("cmd/#").c_str()); publishDiscovery(); mqttPublish(topic("availability"), "online", true); appendLog("MQTT verbunden"); } else { lastError = "MQTT connect failed rc=" + String(mqtt.state()); } return ok; } // ============================================================================ // WLAN / OTA // ============================================================================ void startWiFi() { WiFi.mode(WIFI_STA); WiFi.setHostname("viesta-klima"); WiFi.begin(WIFI_SSID, WIFI_PASS); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) { delay(250); } if (WiFi.status() != WL_CONNECTED) { WiFi.mode(WIFI_AP_STA); WiFi.softAP(AP_SSID, AP_PASS); lastError = "WLAN nicht verbunden, Fallback AP aktiv"; } } void setupOTA() { ArduinoOTA.setHostname(OTA_HOST); ArduinoOTA.setPassword(OTA_PASS); ArduinoOTA.onStart([]() { appendLog("OTA Start"); }); ArduinoOTA.onEnd([]() { appendLog("OTA Ende"); }); ArduinoOTA.onError([](ota_error_t error) { lastError = "OTA Fehler " + String((int)error); }); ArduinoOTA.begin(); } // ============================================================================ // Webserver // ============================================================================ String option(const String& value, const String& current, const String& label) { return ""; } void handleRoot() { String html = ""; html += "Viesta Klima Gateway )rawliteral"; html += "

Viesta Klima Gateway " + String(FW_VERSION) + "

IP " + ipText() + " · " + rssiText() + "
"; html += "
"; int tempInt = (int)round(targetTemp); html += "

Temperatur

" + String(tempInt) + "°
Solltemperatur
"; html += "
"; html += "
Schieberegler entfernt. Temperatur wird jetzt nur noch über Plus/Minus geändert.
"; html += "

Leistung Shelly

" + (isnan(shellyPowerW) ? String("-") : String(shellyPowerW,0)) + "
Watt aktuell
"; html += "
Status: " + String(shellyOnline ? "online" : "offline") + "IP: " + htmlEscape(shellyHost) + "Spannung: " + (isnan(shellyVoltageV) ? String("-") : String(shellyVoltageV,1)) + " VStrom: " + (isnan(shellyCurrentA) ? String("-") : String(shellyCurrentA,3)) + " A
"; html += "
"; html += "

Klima Bedienung

"; html += "
"; html += "
"; html += "

Lüfter direkt

"; html += "
"; html += "
"; html += "
"; html += "

Lamellen direkt

"; html += "
"; html += "
"; html += "
Erweiterte Einstellungen"; html += "
"; html += ""; html += ""; html += ""; html += ""; html += ""; html += "
"; html += "
UART Log
Live-Auszug der seriellen Kommunikation. Live-Aktualisierung ohne Seitenreload alle 3 s.
" + htmlEscape(uartLog) + "
"; html += "

MQTT Climate: Modi off/auto/cool/heat/dry/fan_only · Fan auto/quiet/low/medium/middle/high/focus/diffuse · Swing off/vertical/horizontal/both · Preset none/eco/sleep/comfort

"; html += R"rawliteral()rawliteral"; html += ""; server.send(200, "text/html", html); } void handleSet() { if (server.hasArg("mode")) acMode = server.arg("mode"); if (server.hasArg("temp")) targetTemp = constrain(server.arg("temp").toFloat(), 16.0f, 31.0f); if (server.hasArg("fan")) fanMode = server.arg("fan"); if (server.hasArg("swing")) swingMode = server.arg("swing"); if (server.hasArg("preset")) presetMode = server.arg("preset"); lastCommand = "WEB set"; sendControl(); publishState(); server.sendHeader("Location", "/"); server.send(303); } void handleQuick() { if (server.hasArg("mode")) acMode = server.arg("mode"); if (server.hasArg("fan")) fanMode = server.arg("fan"); if (server.hasArg("swing")) swingMode = server.arg("swing"); if (server.hasArg("preset")) presetMode = server.arg("preset"); if (server.hasArg("temp")) targetTemp = constrain(server.arg("temp").toFloat(), 16.0f, 31.0f); if (server.hasArg("temp_delta")) targetTemp = constrain(targetTemp + server.arg("temp_delta").toFloat(), 16.0f, 31.0f); lastCommand = "WEB quick"; sendControl(); publishState(); server.sendHeader("Location", "/"); server.send(303); } void handleShellyConfig() { if (server.hasArg("host")) { String h = normalizeShellyHost(server.arg("host")); if (h.length() >= 7) { shellyHost = h; prefs.putString("shelly", shellyHost); lastCommand = "WEB Shelly IP=" + shellyHost; pollShelly(); publishState(); } } server.sendHeader("Location", "/"); server.send(303); } void setupWeb() { server.on("/", handleRoot); server.on("/set", handleSet); server.on("/quick", handleQuick); server.on("/shelly", handleShellyConfig); server.on("/restart", []() { server.send(200, "text/plain", "restart"); delay(300); ESP.restart(); }); server.on("/clearlog", []() { uartLog = ""; server.sendHeader("Location", "/"); server.send(303); }); server.on("/state.json", []() { String logTail = uartLog; if (logTail.length() > 6000) logTail = logTail.substring(logTail.length() - 6000); String js = "{"; js += "\"mode\":\"" + jsonEscape(acMode) + "\","; js += "\"target\":" + String(targetTemp,0) + ","; js += "\"current\":" + (isnan(currentTemp) ? String("null") : String(currentTemp,1)) + ","; js += "\"fan\":\"" + jsonEscape(fanMode) + "\","; js += "\"swing\":\"" + jsonEscape(swingMode) + "\","; js += "\"preset\":\"" + jsonEscape(presetMode) + "\","; js += "\"online\":" + String(acOnline ? "true" : "false") + ","; js += "\"command\":\"" + jsonEscape(lastCommand) + "\","; js += "\"ip\":\"" + jsonEscape(ipText()) + "\","; js += "\"rssi\":\"" + jsonEscape(rssiText()) + "\","; js += "\"uptime\":\"" + jsonEscape(uptimeText()) + "\","; js += "\"heap\":" + String(ESP.getFreeHeap()) + ","; js += "\"chip_temp\":" + String(chipTempC(),1) + ","; js += "\"mqtt\":" + String(mqtt.connected() ? "true" : "false") + ","; js += "\"rx_bytes\":" + String(rxBytes) + ","; js += "\"tx_bytes\":" + String(txBytes) + ","; js += "\"rx_frames\":" + String(rxFrames) + ","; js += "\"tx_frames\":" + String(txFrames) + ","; js += "\"checksum_errors\":" + String(checksumErrors) + ","; js += "\"wrong_bytes\":" + String(wrongBytes) + ","; js += "\"last_error\":\"" + jsonEscape(lastError) + "\","; js += "\"fw\":\"" + String(FW_VERSION) + "\","; js += "\"shelly_power\":" + (isnan(shellyPowerW) ? String("null") : String(shellyPowerW,1)) + ","; js += "\"shelly_voltage\":" + (isnan(shellyVoltageV) ? String("null") : String(shellyVoltageV,1)) + ","; js += "\"shelly_current\":" + (isnan(shellyCurrentA) ? String("null") : String(shellyCurrentA,3)) + ","; js += "\"shelly_online\":" + String(shellyOnline ? "true" : "false") + ","; js += "\"shelly_host\":\"" + jsonEscape(shellyHost) + "\","; js += "\"shelly_error\":\"" + jsonEscape(shellyLastError) + "\","; js += "\"log\":\"" + jsonEscape(logTail) + "\""; js += "}"; server.send(200, "application/json", js); }); server.begin(); } // ============================================================================ // Setup / Loop // ============================================================================ void setup() { Serial.begin(115200); delay(300); bootMs = millis(); prefs.begin("viesta-ac", false); loadState(); klimaSerial.begin(KLIMA_BAUD, KLIMA_CONFIG, KLIMA_RX_PIN, KLIMA_TX_PIN); startWiFi(); setupOTA(); setupWeb(); mqtt.setServer(MQTT_HOST, MQTT_PORT); mqtt.setCallback(mqttCallback); mqtt.setBufferSize(4096); appendLog(String("BOOT ") + FW_VERSION); appendLog("IP " + ipText()); connectMqtt(); pollShelly(); publishState(); sendPoll(); } void loop() { ArduinoOTA.handle(); server.handleClient(); processUart(); unsigned long now = millis(); if (now - lastWifiCheckMs > WIFI_RETRY_MS) { lastWifiCheckMs = now; if (WiFi.status() != WL_CONNECTED) { wifiReconnects++; WiFi.disconnect(false); WiFi.begin(WIFI_SSID, WIFI_PASS); } } if (WiFi.status() == WL_CONNECTED) { if (!mqtt.connected() && now - lastMqttCheckMs > MQTT_RETRY_MS) { lastMqttCheckMs = now; connectMqtt(); } if (mqtt.connected()) mqtt.loop(); } if (now - lastPollMs > POLL_INTERVAL_MS) { lastPollMs = now; sendPoll(); } if (now - lastShellyPollMs > SHELLY_POLL_MS) { lastShellyPollMs = now; pollShelly(); } if (now - lastStatePublishMs > STATE_PUBLISH_MS) { lastStatePublishMs = now; // Wichtig: State zyklisch publishen, damit Home Assistant die Steuerung nicht verliert if (isnan(targetTemp) || targetTemp < 16 || targetTemp > 31) targetTemp = 24; publishState(); } if (mqtt.connected() && now - lastDiscoveryMs > DISCOVERY_REFRESH_MS) { lastDiscoveryMs = now; publishDiscovery(); } }