
Zehnder ComfoClime local API
I recently got a Zehnder ComfoAir Q + ComfoClime unit installed. While I can control the ComfoAir via KNX, there is no such option for the ComfoClime. The only way to control it seems to be the smartphone app.
That is not very helpful when integration is needed.
Therefore, wireshark came to the rescue. Fortunatelly, the app is not using HTTPS, thus I was able to see all the packets and URIs in the clear. The comms are also in JSON, which is an another plus.
Without further ado - here it is: gist
All that's needed is the Clime IP address and serial number, both can be found out in the Zehdnder ComfoClime app when switched into installer mode (pin 4210)
We can query the unit status, set temperature profile mode (auto/manual) and corresponding settings. Left out the installer-level temperature threshold, as I imagine those are set once and do not need to be adjusted periodically.
Â
comfoclime_comms.py
#!/usr/bin/env python3
"""
Using Unifi AP, the below can be used to capture packets. Assumes local Wireshark instance is active:
Run wireshark from the AP ssh admin@192.168.2.7 tcpdump -i eth0 -A dst 192.168.20.81 or src 192.168.20.81 -w - | wireshark -k -i -
Run wireshark from the AP ssh admin@192.168.2.7 tcpdump -i any -A dst 192.168.20.81 or src 192.168.20.81 -w - | wireshark -k -i -
Sample commands below:
python3 comfoclime_comms.py 192.168.20.81 MBE70b8f693aaed SET auto_temperature_profile ECO
python3 comfoclime_comms.py 192.168.20.81 MBE70b8f693aaed SET auto_mode true
python3 comfoclime_comms.py 192.168.20.81 MBE70b8f693aaed SET manual_setpoint 20
python3 comfoclime_comms.py 192.168.20.81 MBE70b8f693aaed SET season_auto_mode true
python3 comfoclime_comms.py 192.168.20.81 MBE70b8f693aaed SET manual_season HEAT
python3 comfoclime_comms.py 192.168.20.81 MBE70b8f693aaed GET CLIME_ALL
python3 comfoclime_comms.py 192.168.20.81 MBE70b8f693aaed GET SYSTEM_ALL
"""
import json
import requests
import argparse
import sys
import datetime
COMFOCLIME_UUID = ""
COMFOCLIME_IP = ""
# URLs
DASHBOARD_URL = "http://{ip}/system/{uuid}/dashboard"
COMFOCLIME_URL = "http://{ip}/device/{uuid}/definition"
THERMAL_PROFILE_URL = "http://{ip}/system/{uuid}/thermalprofile"
DEVICE_GENERIC_URL = "http://{ip}/device"
# "empty" packets
DEVICE_GENERIC_PUT_PAYLOAD = '{{"uuid":"{uuid}","systemUuid":"{uuid}","setPointTemperature":null,"temperatureProfile":null,"fanSpeed":null,"status":null}}'
SYSTEM_THERMAL_PROFILE_PUT_PAYLOAD = '{"@type":null,"name":null,"displayName":null,"description":null,"timestamp":"2025-03-13T17:32:41.156726","season":{"status":null,"season":null,"heatingThresholdTemperature":null,"coolingThresholdTemperature":null}}'
def process_response(response):
if response.status_code != 200:
print("Failure")
print(response.content, response.status_code)
sys.exit(1)
else:
print("OK")
sys.exit(0)
def set_season_auto_mode(auto_mode):
"""
Configure temperature profile (relevant when the auto mode is ON)
"""
if not isinstance(auto_mode, bool):
raise Exception("Boolean expected")
data = json.loads(SYSTEM_THERMAL_PROFILE_PUT_PAYLOAD)
data["season"]["status"] = 1 if auto_mode == True else 0
data["timestamp"] = datetime.datetime.now().isoformat()
headers = {"content-type": "application/json; charset=utf-8"}
response = requests.put(
THERMAL_PROFILE_URL.format(ip=COMFOCLIME_IP, uuid=COMFOCLIME_UUID),
json=data,
headers=headers,
)
process_response(response)
def set_season(season):
"""
Configure temperature profile (relevant when the auto mode is ON)
"""
data = json.loads(SYSTEM_THERMAL_PROFILE_PUT_PAYLOAD)
if season == "HEAT":
data["season"]["season"] = 1
elif season == "MIDDLE":
data["season"]["season"] = 0
elif season == "COOL":
data["season"]["season"] = 2
else:
raise Exception(
"Invalid season profile - supported values are HEAT, MIDDLE and COOL"
)
data["timestamp"] = datetime.datetime.now().isoformat()
headers = {"content-type": "application/json; charset=utf-8"}
response = requests.put(
THERMAL_PROFILE_URL.format(ip=COMFOCLIME_IP, uuid=COMFOCLIME_UUID),
json=data,
headers=headers,
)
process_response(response)
def set_manual_setpoint(temperature):
"""
Configure temperature profile (relevant when the auto mode is ON)
"""
data = json.loads(DEVICE_GENERIC_PUT_PAYLOAD.format(uuid=COMFOCLIME_UUID))
data["setPointTemperature"] = temperature
headers = {"content-type": "application/json; charset=utf-8"}
response = requests.put(
DEVICE_GENERIC_URL.format(ip=COMFOCLIME_IP), json=data, headers=headers
)
process_response(response)
def set_temperature_profile(temperature_profile):
"""
Configure temperature profile (relevant when the auto mode is ON)
"""
data = json.loads(DEVICE_GENERIC_PUT_PAYLOAD.format(uuid=COMFOCLIME_UUID))
if temperature_profile == "ECO":
data["temperatureProfile"] = 2
elif temperature_profile == "COMFORT":
data["temperatureProfile"] = 0
elif temperature_profile == "POWER":
data["temperatureProfile"] = 1
else:
raise Exception(
"Invalid temperature profile - supported values are ECO, COMFORT and POWER"
)
headers = {"content-type": "application/json; charset=utf-8"}
response = requests.put(
DEVICE_GENERIC_URL.format(ip=COMFOCLIME_IP), json=data, headers=headers
)
process_response(response)
def set_auto_mode(auto_mode):
"""
Configure whether auto mode is ON or OFF
"""
if not isinstance(auto_mode, bool):
raise Exception("Boolean expected")
data = json.loads(DEVICE_GENERIC_PUT_PAYLOAD.format(uuid=COMFOCLIME_UUID))
data["status"] = 1 if auto_mode == True else 0
headers = {"content-type": "application/json; charset=utf-8"}
response = requests.put(
DEVICE_GENERIC_URL.format(ip=COMFOCLIME_IP), json=data, headers=headers
)
process_response(response)
def get_system_status():
response = requests.get(
DASHBOARD_URL.format(ip=COMFOCLIME_IP, uuid=COMFOCLIME_UUID)
)
return response.json()
def get_comfoclime_status():
response = requests.get(
COMFOCLIME_URL.format(ip=COMFOCLIME_IP, uuid=COMFOCLIME_UUID)
)
return response.json()
def main():
global COMFOCLIME_UUID, COMFOCLIME_IP
parser = argparse.ArgumentParser(description="ComfoClime basic control interface")
parser.add_argument("device_ip", type=str, help="Device IP Address")
parser.add_argument("device_uuid", type=str, help="Device UUID")
parser.add_argument("mode", choices=["GET", "SET"], help="Mode: GET or SET")
parser.add_argument("item_name", type=str, help="Item name")
parser.add_argument(
"value", type=str, nargs="?", help="Value (only required for SET mode)"
)
args = parser.parse_args()
# print(f"Device IP: {args.device_ip}")
# print(f"Device UUID: {args.device_uuid}")
# print(f"Mode: {args.mode}")
# print(f"Item Name: {args.item_name}")
# if args.mode == "SET":
# print(f"Value: {args.value}")
COMFOCLIME_IP = args.device_ip
COMFOCLIME_UUID = args.device_uuid
# # detect season (important for setpoint thresholding)
# season = get_system_status()["season"]
if args.mode == "SET" and args.value is None:
parser.error("The SET mode requires a value argument.")
SET_ITEMS_SUPPORTED = [
"auto_mode",
"auto_temperature_profile",
"manual_setpoint",
"season_auto_mode",
"manual_season",
]
if args.mode == "SET" and args.item_name not in SET_ITEMS_SUPPORTED:
parser.error(
"Only the following items can be set: {}".format(
", ".join(SET_ITEMS_SUPPORTED)
)
)
if (
args.mode == "GET"
and not args.item_name.startswith("CLIME_")
and not args.item_name.startswith("SYSTEM_")
):
parser.error(
"The GET mode item names need to start with either SYSTEM_ or CLIME_"
)
if args.mode == "SET" and args.item_name == "auto_mode":
if args.value not in ["true", "false"]:
parser.error("The auto_mode value needs to be either 'true' or 'false'")
# execute
set_auto_mode(args.value == "true")
if args.mode == "SET" and args.item_name == "season_auto_mode":
if args.value not in ["true", "false"]:
parser.error(
"The set_season_auto_mode value needs to be either 'true' or 'false'"
)
# execute
set_season_auto_mode(args.value == "true")
if args.mode == "SET" and args.item_name == "auto_temperature_profile":
MODES = ["ECO", "COMFORT", "POWER"]
if args.value not in MODES:
parser.error(
"The auto_temperature_profile value needs to be one of: {}".format(
", ".join(MODES)
)
)
set_temperature_profile(args.value)
if args.mode == "SET" and args.item_name == "manual_season":
MODES = ["HEAT", "MIDDLE", "COOL"]
if args.value not in MODES:
parser.error(
"The manual_season value needs to be one of: {}".format(
", ".join(MODES)
)
)
set_season(args.value)
if args.mode == "SET" and args.item_name == "manual_setpoint":
try:
val = round(float(args.value) * 2) / 2 # round to the nearest half degree
except ValueError:
parser.error("Please provide a number")
# print("Setting temperature to {}".format(val))
if val < 18 or val > 28:
parser.error("The temperature needs to between 18 and 28C")
set_manual_setpoint(val)
if args.mode == "GET" and args.item_name == "CLIME_ALL":
print(json.dumps(get_comfoclime_status()))
if args.mode == "GET" and args.item_name == "SYSTEM_ALL":
print(json.dumps(get_system_status()))
if __name__ == "__main__":
main()