Created
March 14, 2025 09:18
-
-
Save petrklus/eac1b36047f44b0dac881e63066d2c61 to your computer and use it in GitHub Desktop.
Zehnder ComfoClime comms - reverse-engineered, local API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
Using Unifi AP, the below can be used to capture packets. Assumes local Wireshark instance is active, 192.168.2.7 is the AP's IP and 192.168.20.81 is ComfoClime IP: | |
Run wireshark from the AP ssh [email protected] 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 [email protected] 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(get_comfoclime_status()) | |
if args.mode == "GET" and args.item_name == "SYSTEM_ALL": | |
print(get_system_status()) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment