From 275a4c20b59c6ce0933137ad8a9fb1d71c181f67 Mon Sep 17 00:00:00 2001 From: JP Roemer <2822534+0rax@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:14:38 +0100 Subject: [PATCH 1/2] Detect room control type using IoT Hub Child to select between AC and Thermostat --- custom_components/cozytouch/hub.py | 6 +- custom_components/cozytouch/model.py | 144 +++++++++++++++++++++------ 2 files changed, 120 insertions(+), 30 deletions(-) diff --git a/custom_components/cozytouch/hub.py b/custom_components/cozytouch/hub.py index f100069..bd97f4e 100644 --- a/custom_components/cozytouch/hub.py +++ b/custom_components/cozytouch/hub.py @@ -7,6 +7,7 @@ from datetime import UTC, datetime, time as t, timedelta, timezone import json import logging +from typing import Any, Dict from aiohttp import ClientSession, ContentTypeError, FormData @@ -328,7 +329,7 @@ def get_zone_name(self, zoneId: int | None = None) -> str: return str(zoneId) - def get_model_infos(self, deviceId: int | None = None) -> str: + def get_model_infos(self, deviceId: int | None = None) -> Dict[str, Any]: """Get model infos.""" if not deviceId: deviceId = self._deviceId @@ -336,6 +337,7 @@ def get_model_infos(self, deviceId: int | None = None) -> str: for dev in self._devices: if dev["deviceId"] == deviceId: zoneId = dev["zoneId"] + tags = dev["tags"] # Special case for sub-devices, use master zone Id for masterDev in self._devices: @@ -350,7 +352,7 @@ def get_model_infos(self, deviceId: int | None = None) -> str: zoneId = masterDev["zoneId"] break - return get_model_infos(dev["modelId"], self.get_zone_name(zoneId)) + return get_model_infos(dev["modelId"], self.get_zone_name(zoneId), tags) return get_model_infos(-1) diff --git a/custom_components/cozytouch/model.py b/custom_components/cozytouch/model.py index aa9df2c..436d4d6 100644 --- a/custom_components/cozytouch/model.py +++ b/custom_components/cozytouch/model.py @@ -18,6 +18,7 @@ """ # noqa: D205 from enum import StrEnum +from typing import Dict, List from homeassistant.components.climate import HVACMode from homeassistant.components.climate.const import ( @@ -54,7 +55,7 @@ class CozytouchDeviceType(StrEnum): HUB = "hub" -def get_model_infos(modelId: int, zoneName: str | None = None): +def get_model_infos(modelId: int, zoneName: str | None = None, tags: List[Dict[str, str]] | None = None): """Return infos from model ID.""" modelInfos = {"modelId": modelId, "HVACModesCapabilityId": {7, 8}} @@ -186,38 +187,113 @@ def get_model_infos(modelId: int, zoneName: str | None = None): } elif modelId >= 557 and modelId <= 561: - name = "Air Conditioner " + name = "Unknown product (" + str(modelId) + ")" + modelInfos["type"] = CozytouchDeviceType.UNKNOWN + # modelInfos["fanModes"] = {} + # modelInfos["swingModes"] = {} + modelInfos["HVACModes"] = { + 0: HVACMode.OFF, + 4: HVACMode.HEAT, + } + + childrenIds = [] + for tag in tags if tags is not None else []: + if ( + "label" in tag + and tag["label"] == "iothubChildrenIds" + and "value" in tag + ): + childrenIds = tag["value"].split(",") + break + + if any(childId.startswith("UI_") for childId in childrenIds): # Air conditioner detected + name = "Air Conditioner " + modelInfos["type"] = CozytouchDeviceType.AC + modelInfos["currentTemperatureAvailable"] = False + modelInfos["quietModeAvailable"] = True + + modelInfos["fanModes"] = { + 1: FAN_LOW, + 2: FAN_MEDIUM, + 3: FAN_HIGH, + 5: FAN_AUTO, + } + + modelInfos["swingModes"] = { + 1: SWING_MODE_UP, + 2: SWING_MODE_MIDDLE_UP, + 3: SWING_MODE_MIDDLE_DOWN, + 4: SWING_MODE_DOWN, + } + + modelInfos["HVACModes"] = { + 0: HVACMode.OFF, + 1: HVACMode.AUTO, + 3: HVACMode.COOL, + 4: HVACMode.HEAT, + 7: HVACMode.FAN_ONLY, + 8: HVACMode.DRY, + } + elif any(childId.startswith("THZONE_") for childId in childrenIds): # Thermostat detected + name = "Thermostat (THZONE) " + if zoneName is not None: + modelInfos["name"] = name + "(" + zoneName + ")" + else: + modelInfos["name"] = name + "(#" + str(modelId - 556) + ")" + + modelInfos["type"] = CozytouchDeviceType.THERMOSTAT + modelInfos["currentTemperatureAvailable"] = False + modelInfos["currentTemperatureAvailableZ1"] = True + modelInfos["quietModeAvailable"] = False + + modelInfos["HVACModes"] = { + 0: HVACMode.OFF, + 4: HVACMode.HEAT, + } + modelInfos["HeatingModes"] = { + 0: HEATING_MODE_MANUAL, + 3: HEATING_MODE_ECO_PLUS, + 4: HEATING_MODE_PROG, + } + # else: # Fallback to AC if none found to keep backward compatibility + # name = "Air Conditioner " + # if zoneName is not None: + # modelInfos["name"] = name + "(" + zoneName + ")" + # else: + # modelInfos["name"] = name + "(#" + str(modelId - 556) + ")" + + # modelInfos["type"] = CozytouchDeviceType.AC + # modelInfos["currentTemperatureAvailable"] = False + # modelInfos["quietModeAvailable"] = True + + # modelInfos["fanModes"] = { + # 1: FAN_LOW, + # 2: FAN_MEDIUM, + # 3: FAN_HIGH, + # 5: FAN_AUTO, + # } + + # modelInfos["swingModes"] = { + # 1: SWING_MODE_UP, + # 2: SWING_MODE_MIDDLE_UP, + # 3: SWING_MODE_MIDDLE_DOWN, + # 4: SWING_MODE_DOWN, + # } + + # modelInfos["HVACModes"] = { + # 0: HVACMode.OFF, + # 1: HVACMode.AUTO, + # 3: HVACMode.COOL, + # 4: HVACMode.HEAT, + # 7: HVACMode.FAN_ONLY, + # 8: HVACMode.DRY, + # } + if zoneName is not None: modelInfos["name"] = name + "(" + zoneName + ")" else: modelInfos["name"] = name + "(#" + str(modelId - 556) + ")" - modelInfos["type"] = CozytouchDeviceType.AC - modelInfos["currentTemperatureAvailable"] = False - modelInfos["quietModeAvailable"] = True - - modelInfos["fanModes"] = { - 1: FAN_LOW, - 2: FAN_MEDIUM, - 3: FAN_HIGH, - 5: FAN_AUTO, - } - - modelInfos["swingModes"] = { - 1: SWING_MODE_UP, - 2: SWING_MODE_MIDDLE_UP, - 3: SWING_MODE_MIDDLE_DOWN, - 4: SWING_MODE_DOWN, - } - - modelInfos["HVACModes"] = { - 0: HVACMode.OFF, - 1: HVACMode.AUTO, - 3: HVACMode.COOL, - 4: HVACMode.HEAT, - 7: HVACMode.FAN_ONLY, - 8: HVACMode.DRY, - } elif modelId >= 562 and modelId <= 570: name = "Air Conditioner User Interface " @@ -298,6 +374,18 @@ def get_model_infos(modelId: int, zoneName: str | None = None): 4: HVACMode.HEAT, } + elif modelId >= 1505 and modelId <= 1513: + name = "Thermostat Thermal Zone " + if zoneName is not None: + modelInfos["name"] = name + "(" + zoneName + ")" + else: + modelInfos["name"] = name + "(#" + str(modelId - 561) + ")" + + modelInfos["type"] = CozytouchDeviceType.THERMOSTAT + modelInfos["HVACModes"] = { + 0: HVACMode.OFF, + } + elif modelId == 1543: modelInfos["name"] = "Asama Connecté II Ventilo 1750W Blanc" modelInfos["type"] = CozytouchDeviceType.TOWEL_RACK From 7c3f5d99bd4d72c477d86906c2347f24b1da8a73 Mon Sep 17 00:00:00 2001 From: JP Roemer <2822534+0rax@users.noreply.github.com> Date: Tue, 9 Dec 2025 02:57:06 +0100 Subject: [PATCH 2/2] Hide unused capabilities for ROOM thermostat --- custom_components/cozytouch/capability.py | 30 ++++++--- custom_components/cozytouch/hub.py | 10 +-- custom_components/cozytouch/model.py | 81 ++++++++++++----------- 3 files changed, 67 insertions(+), 54 deletions(-) diff --git a/custom_components/cozytouch/capability.py b/custom_components/cozytouch/capability.py index 16c1f4e..5c21fe9 100644 --- a/custom_components/cozytouch/capability.py +++ b/custom_components/cozytouch/capability.py @@ -68,6 +68,10 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability.pop("lowestValueCapabilityId") capability.pop("highestValueCapabilityId") capability["icon"] = "mdi:heat-pump" + elif modelInfos["type"] == CozytouchDeviceType.THERMOSTAT: + capability["name"] = "heat" + capability["icon"] = "mdi:thermostat" + capability["targetCapabilityId"] = 40 else: capability["name"] = "heat" @@ -369,9 +373,8 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["highestValueCapabilityId"] = 161 elif capabilityId == 177: - if modelInfos["type"] == CozytouchDeviceType.GAZ_BOILER: + if modelInfos["type"] in [CozytouchDeviceType.GAZ_BOILER, CozytouchDeviceType.THERMOSTAT]: return {} - capability["name"] = "target_cool_temperature" capability["type"] = "temperature_adjustment_number" capability["category"] = "sensor" @@ -623,21 +626,24 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["icon"] = "mdi:fire" elif capabilityId == 100505: + if modelInfos["type"] == CozytouchDeviceType.THERMOSTAT: + return {} capability["name"] = "powerful_mode" capability["type"] = "switch" capability["category"] = "sensor" capability["icon"] = "mdi:wind-power" elif capabilityId == 100506: - if modelInfos["type"] == CozytouchDeviceType.TOWEL_RACK: - capability = {} - else: - capability["name"] = "presence_mode" - capability["type"] = "switch" - capability["category"] = "sensor" - capability["icon"] = "mdi:account" + if modelInfos["type"] in [CozytouchDeviceType.TOWEL_RACK, CozytouchDeviceType.THERMOSTAT]: + return {} + capability["name"] = "presence_mode" + capability["type"] = "switch" + capability["category"] = "sensor" + capability["icon"] = "mdi:account" elif capabilityId == 100507: + if modelInfos["type"] == CozytouchDeviceType.THERMOSTAT: + return {} capability["name"] = "eco_mode" capability["type"] = "switch" capability["category"] = "sensor" @@ -714,18 +720,24 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["category"] = "diag" elif capabilityId == 100802: + if modelInfos["type"] == CozytouchDeviceType.THERMOSTAT: + return {} capability["name"] = "quiet_mode" capability["type"] = "switch" capability["category"] = "sensor" capability["icon"] = "mdi:fan-minus" elif capabilityId == 100804: + if modelInfos["type"] == CozytouchDeviceType.THERMOSTAT: + return {} capability["name"] = "swing_mode" capability["type"] = "switch" capability["category"] = "sensor" capability["icon"] = "mdi:arrow-oscillating" elif capabilityId == 104044: + if modelInfos["type"] == CozytouchDeviceType.THERMOSTAT: + return {} capability["name"] = "boost_mode" capability["type"] = "switch" capability["category"] = "sensor" diff --git a/custom_components/cozytouch/hub.py b/custom_components/cozytouch/hub.py index bd97f4e..94c7652 100644 --- a/custom_components/cozytouch/hub.py +++ b/custom_components/cozytouch/hub.py @@ -227,7 +227,7 @@ def update_devices_from_json_data(self, json_data) -> None: "modelId": remote_device["modelId"], "productId": remote_device["productId"], "zoneId": remote_device["zoneId"], - "modelInfos": get_model_infos(remote_device["modelId"]), + "modelInfos": get_model_infos(remote_device["modelId"], remote_device["tags"]), "capabilities": [], "tags": [], } @@ -329,7 +329,7 @@ def get_zone_name(self, zoneId: int | None = None) -> str: return str(zoneId) - def get_model_infos(self, deviceId: int | None = None) -> Dict[str, Any]: + def get_model_infos(self, deviceId: int | None = None) -> dict: """Get model infos.""" if not deviceId: deviceId = self._deviceId @@ -352,9 +352,9 @@ def get_model_infos(self, deviceId: int | None = None) -> Dict[str, Any]: zoneId = masterDev["zoneId"] break - return get_model_infos(dev["modelId"], self.get_zone_name(zoneId), tags) + return get_model_infos(dev["modelId"], tags, self.get_zone_name(zoneId)) - return get_model_infos(-1) + return get_model_infos(-1, []) def get_serial_number(self, deviceId: int | None = None) -> str: """Get serial number.""" @@ -376,7 +376,7 @@ def get_capabilities_for_device(self, deviceId: int | None = None): capabilities = [] for dev in self._devices: if dev["deviceId"] == deviceId: - modelInfos = get_model_infos(dev["modelId"]) + modelInfos = get_model_infos(dev["modelId"], dev["tags"]) for capability in dev["capabilities"]: capability_infos = get_capability_infos( modelInfos, diff --git a/custom_components/cozytouch/model.py b/custom_components/cozytouch/model.py index 436d4d6..427afa2 100644 --- a/custom_components/cozytouch/model.py +++ b/custom_components/cozytouch/model.py @@ -18,7 +18,7 @@ """ # noqa: D205 from enum import StrEnum -from typing import Dict, List +import logging from homeassistant.components.climate import HVACMode from homeassistant.components.climate.const import ( @@ -55,7 +55,7 @@ class CozytouchDeviceType(StrEnum): HUB = "hub" -def get_model_infos(modelId: int, zoneName: str | None = None, tags: List[Dict[str, str]] | None = None): +def get_model_infos(modelId: int, tags: list, zoneName: str | None = None) -> dict: """Return infos from model ID.""" modelInfos = {"modelId": modelId, "HVACModesCapabilityId": {7, 8}} @@ -189,8 +189,6 @@ def get_model_infos(modelId: int, zoneName: str | None = None, tags: List[Dict[s elif modelId >= 557 and modelId <= 561: name = "Unknown product (" + str(modelId) + ")" modelInfos["type"] = CozytouchDeviceType.UNKNOWN - # modelInfos["fanModes"] = {} - # modelInfos["swingModes"] = {} modelInfos["HVACModes"] = { 0: HVACMode.OFF, 4: HVACMode.HEAT, @@ -235,15 +233,18 @@ def get_model_infos(modelId: int, zoneName: str | None = None, tags: List[Dict[s 8: HVACMode.DRY, } elif any(childId.startswith("THZONE_") for childId in childrenIds): # Thermostat detected - name = "Thermostat (THZONE) " + # NOTE: not sure about the name here as we are indeed controlling the Thermostat setting but this in-turn activate underfloor heating. + name = "Thermostat " if zoneName is not None: modelInfos["name"] = name + "(" + zoneName + ")" else: modelInfos["name"] = name + "(#" + str(modelId - 556) + ")" modelInfos["type"] = CozytouchDeviceType.THERMOSTAT - modelInfos["currentTemperatureAvailable"] = False + modelInfos["currentTemperatureAvailable"] = True modelInfos["currentTemperatureAvailableZ1"] = True + modelInfos["currentTemperatureAvailableZ2"] = False + modelInfos["overrideModeAvailable"] = True modelInfos["quietModeAvailable"] = False modelInfos["HVACModes"] = { @@ -255,39 +256,39 @@ def get_model_infos(modelId: int, zoneName: str | None = None, tags: List[Dict[s 3: HEATING_MODE_ECO_PLUS, 4: HEATING_MODE_PROG, } - # else: # Fallback to AC if none found to keep backward compatibility - # name = "Air Conditioner " - # if zoneName is not None: - # modelInfos["name"] = name + "(" + zoneName + ")" - # else: - # modelInfos["name"] = name + "(#" + str(modelId - 556) + ")" - - # modelInfos["type"] = CozytouchDeviceType.AC - # modelInfos["currentTemperatureAvailable"] = False - # modelInfos["quietModeAvailable"] = True - - # modelInfos["fanModes"] = { - # 1: FAN_LOW, - # 2: FAN_MEDIUM, - # 3: FAN_HIGH, - # 5: FAN_AUTO, - # } - - # modelInfos["swingModes"] = { - # 1: SWING_MODE_UP, - # 2: SWING_MODE_MIDDLE_UP, - # 3: SWING_MODE_MIDDLE_DOWN, - # 4: SWING_MODE_DOWN, - # } - - # modelInfos["HVACModes"] = { - # 0: HVACMode.OFF, - # 1: HVACMode.AUTO, - # 3: HVACMode.COOL, - # 4: HVACMode.HEAT, - # 7: HVACMode.FAN_ONLY, - # 8: HVACMode.DRY, - # } + else: # Fallback to AC if none found to keep backward compatibility + name = "Air Conditioner " + if zoneName is not None: + modelInfos["name"] = name + "(" + zoneName + ")" + else: + modelInfos["name"] = name + "(#" + str(modelId - 556) + ")" + + modelInfos["type"] = CozytouchDeviceType.AC + modelInfos["currentTemperatureAvailable"] = False + modelInfos["quietModeAvailable"] = True + + modelInfos["fanModes"] = { + 1: FAN_LOW, + 2: FAN_MEDIUM, + 3: FAN_HIGH, + 5: FAN_AUTO, + } + + modelInfos["swingModes"] = { + 1: SWING_MODE_UP, + 2: SWING_MODE_MIDDLE_UP, + 3: SWING_MODE_MIDDLE_DOWN, + 4: SWING_MODE_DOWN, + } + + modelInfos["HVACModes"] = { + 0: HVACMode.OFF, + 1: HVACMode.AUTO, + 3: HVACMode.COOL, + 4: HVACMode.HEAT, + 7: HVACMode.FAN_ONLY, + 8: HVACMode.DRY, + } if zoneName is not None: modelInfos["name"] = name + "(" + zoneName + ")" @@ -379,7 +380,7 @@ def get_model_infos(modelId: int, zoneName: str | None = None, tags: List[Dict[s if zoneName is not None: modelInfos["name"] = name + "(" + zoneName + ")" else: - modelInfos["name"] = name + "(#" + str(modelId - 561) + ")" + modelInfos["name"] = name + "(#" + str(modelId - 1504) + ")" modelInfos["type"] = CozytouchDeviceType.THERMOSTAT modelInfos["HVACModes"] = {