Kermi Heat Pump X-Center Integration (kind of) - Without Modbus

Hi there!

I was a silent reader till now, but now i finally registered and thought i maybe can give something back to the community. I have a Kermi Heat Pump, but no Modbus. I’ve read a thread here where some other people searched for it too, but no solution.

So here’s what i did in summary:

  • In installed the MQTT Integration in HA and configured it
  • I have a Python Script on my HA-Server that logs in to Heat Pump Web Interface and grabs the Data from the API and writes it to MQTT (Autodiscovery and States)
  • This script is run through automation and the shell_command integration every minute

The downsides of this integration are:

  • You have to have specific items in your favorites in the heat pump web interface
  • If the Web-Interface in any way changes (URLs, API-JSON-Strucutre, …) the script may break
  • It’s only one-way, you only can read data, not write
  • The Heat Pumps may be different in version and functions and some adjustments in the code may be neccessary, but the script should give you a good start to understand everything

If this is fine for you and you know about the things i talked about, here’s my scraper python-script:

import json
import requests
import paho.mqtt.client as mqtt
import time
import unicodedata
import re

# Konfigurationswerte
DEBUG = False  # Auf True setzen, um Log-Ausgaben zu erhalten; sonst auf False
LOGIN_URL = "http://<ip-of-your-hp>/api/Security/Login"
DATA_URL = "http://<ip-of-your-hp>/api/Favorite/GetFavorites/00000000-0000-0000-0000-000000000000"
MQTT_BROKER = "core-mosquitto"
MQTT_PORT = 1883
MQTT_USERNAME = "<mqtt-username>"     # Kann auch ein HA-Benutzer sein
MQTT_PASSWORD = "<mqtt-password>"     # Kann auch ein HA-Benutzer sein
MQTT_TOPIC_PREFIX = "mqtt"      # Sensorwerte
DISCOVERY_PREFIX = "mqtt-ad"    # Auto-Discovery Prefix für HA

PASSWORD = "<webinterface-login-password>"  # i.d.R. letzte 4 Stellen der Seriennummer
WP_DEVICE_NAME = "wp_kermi"

def normalize_key(name):
    """Erzeugt einen konsistenten Schlüssel: kleingeschrieben, ohne Umlaute und Sonderzeichen."""
    name = name.lower()
    name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii')
    name = re.sub(r'[\s-]+', '_', name)
    name = re.sub(r'[^a-z0-9_]', '', name)
    return name

def log_extracted_data(extracted):
    """Schreibt die vom Scraper verarbeiteten Sensorwerte (Name, Wert, Einheit) in die Log-Datei."""
    if DEBUG:
        try:
            with open("kermi_scraper.log", "a", encoding="utf-8") as f:
                f.write(json.dumps(extracted, ensure_ascii=False, indent=2))
                f.write("\n\n")
        except Exception as e:
            print(f"Fehler beim Schreiben ins Log: {e}")

def login():
    session = requests.Session()
    payload = {"Password": PASSWORD}
    headers = {"Content-Type": "application/json"}
    response = session.post(LOGIN_URL, json=payload, headers=headers)
    print(f"Login-Response Code: {response.status_code}")
    if response.status_code != 200:
        print(f"Fehler beim Login: {response.text}")
        response.raise_for_status()
    try:
        data = response.json()
        if not data.get("isValid", False):
            print("Fehler: Login war nicht erfolgreich!")
            return None
    except json.JSONDecodeError:
        print("Fehler: Keine gültige JSON-Antwort beim Login.")
        return None
    return session

def fetch_data(session):
    payload = {"WithDetails": True, "OnlyHomeScreen": True}
    headers = {"Content-Type": "application/json"}
    response = session.post(DATA_URL, json=payload, headers=headers)
    print(f"Datenabruf-Response Code: {response.status_code}")
    if response.status_code != 200:
        print(f"Fehler beim Abrufen der Daten: {response.text}")
        response.raise_for_status()
    try:
        return response.json()
    except json.JSONDecodeError:
        print("Fehler: Die API hat keine gültige JSON-Antwort zurückgegeben.")
        print(f"Antworttext: {response.text}")
        return None

def extract_data(api_response):
    """Extrahiert Daten aus `ResponseData` und `VisualizationDatapoints`."""
    all_sensors = []

    # Daten aus `ResponseData`
    for item in api_response.get("ResponseData", []):
        if "$type" not in item or "DatapointValue" not in item or "DatapointConfig" not in item:
            continue
        config = item["DatapointConfig"]
        value = round(item["DatapointValue"]["Value"], 2) if isinstance(item["DatapointValue"]["Value"], float) else item["DatapointValue"]["Value"]
        name = config["DisplayName"]
        unit = config.get("Unit", "")
        all_sensors.append({
            "name": name,
            "value": value,
            "unit": unit
        })

    # Zusätzliche Daten aus `VisualizationDatapoints`
    for item in api_response.get("ResponseData", []):
        if "VisualizationDatapoints" in item:
            for datapoint in item["VisualizationDatapoints"].get("$values", []):
                config = datapoint["Config"]
                value = round(datapoint["DatapointValue"]["Value"], 2) if isinstance(datapoint["DatapointValue"]["Value"], float) else datapoint["DatapointValue"]["Value"]
                name = config["DisplayName"]
                unit = config.get("Unit", "")
                all_sensors.append({
                    "name": name,
                    "value": value,
                    "unit": unit
                })
    
    return all_sensors

def send_mqtt(data):
    if not data:
        print("Keine Daten vorhanden, MQTT wird nicht gesendet.")
        return

    #client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) # Funktioniert nicht im HA docker container
    client = mqtt.Client()
    client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
    client.connect(MQTT_BROKER, MQTT_PORT, 60)

    published = set()
    extracted_sensors = []  # Hier werden die extrahierten Sensorwerte gesammelt

    for item in data:
        name = item["name"]
        sensor_key = normalize_key(name)
        if sensor_key in published:
            continue
        published.add(sensor_key)
        value = item["value"]
        unit = item.get("unit", "")

        # Discovery-Nachricht erstellen
        discovery_topic = f"{DISCOVERY_PREFIX}/sensor/{sensor_key}/config"
        state_topic = f"{MQTT_TOPIC_PREFIX}/{sensor_key}"
        config_payload = {
            "name": f"Kermi: {name}",
            "state_topic": state_topic,
            "value_template": "{{ value_json.value }}",
            "unit_of_measurement": unit,
            "device_class": None,
            "icon": "mdi:flash",
            "unique_id": f"kermi_{sensor_key}",
            "availability_topic": "mqtt/status",
            "device": {
                "name": WP_DEVICE_NAME,
                "identifiers": [WP_DEVICE_NAME]
            }
        }

        client.publish(discovery_topic, json.dumps(config_payload), retain=True)
        print(f"Discovery gesendet: {discovery_topic}")

    # Kurze Pause, damit HA die Discovery-Nachrichten verarbeiten kann
    time.sleep(3)

    # State-Nachrichten senden und extrahierte Sensorwerte sammeln
    for item in data:
        name = item["name"]
        sensor_key = normalize_key(name)
        state_topic = f"{MQTT_TOPIC_PREFIX}/{sensor_key}"
        value = item["value"]
        unit = item.get("unit", "")

        client.publish(state_topic, json.dumps({"value": value, "unit": unit}), retain=False)
        print(f"Sende MQTT: {state_topic} -> {value} {unit}")
        extracted_sensors.append({"name": name, "value": value, "unit": unit})

    log_extracted_data(extracted_sensors)
    client.disconnect()

try:
    session = login()
    if session:
        print("Login erfolgreich!")
        api_response = fetch_data(session)
        if api_response:
            print("Daten erfolgreich abgerufen!")
            extracted_data = extract_data(api_response)
            send_mqtt(extracted_data)
            print("Daten erfolgreich gesendet!")
        else:
            print("Fehler: Die API hat keine verwertbaren Daten geliefert.")
    else:
        print("Fehler: Kein gültiges Session-Objekt erhalten.")
except Exception as e:
    print(f"Fehler: {e}")

Here’s an example how this could look in HA:

If you need help setting up, just ask :slight_smile:

Otherwise, have fun!

Greetings