Skip to content

Instantly share code, notes, and snippets.

@petrklus
Created March 14, 2025 09:18
Show Gist options
  • Save petrklus/eac1b36047f44b0dac881e63066d2c61 to your computer and use it in GitHub Desktop.
Save petrklus/eac1b36047f44b0dac881e63066d2c61 to your computer and use it in GitHub Desktop.
Zehnder ComfoClime comms - reverse-engineered, local API
#!/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