diff --git a/README.md b/README.md index c9ef95a..8f91b93 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This has been tested using on : - `Atlantic Naia 2 Micro 25` gas boiler using a `Ǹavilink Radio-Connect 128` thermostat - `Atlantic Loria Duo 6006 R32` heat pump using a `Navilink Radio-Connect 128` thermostat - `Takao M3` air conditionning + - `HUB Navizone` air conditionning gateway driving room units - `Kelud 1750W` towel rack - `Sauter Asama Connecté II Ventilo 1750W` towel rack diff --git a/custom_components/cozytouch/capability.py b/custom_components/cozytouch/capability.py index 16c1f4e..aecefa4 100644 --- a/custom_components/cozytouch/capability.py +++ b/custom_components/cozytouch/capability.py @@ -6,8 +6,19 @@ from .model import CozytouchDeviceType -def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: str): # noqa: C901 - """Get capabilities for a device.""" +def get_capability_infos( # noqa: C901 + modelInfos: dict, + capabilityId: int, + capabilityValue: str, + availableCapabilityIds: set[int], +): + """Get capabilities for a device. + + availableCapabilityIds is what the device actually reports. Optional + features are declared per model, but the same model id is reused across + hardware that does not always implement them, so they are only wired up + when the device backs them. + """ modelId = modelInfos["modelId"] capability = {"modelId": modelId, "capabilityId": capabilityId} @@ -21,9 +32,17 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["lowestValueCapabilityId"] = 160 capability["highestValueCapabilityId"] = 161 - if modelInfos.get("currentTemperatureAvailable", True): + if ( + modelInfos.get("currentTemperatureAvailable", True) + and 117 in availableCapabilityIds + ): capability["currentValueCapabilityId"] = 117 + # 181 carries the mode the device is really running, which is not always + # the one it was asked for + if 181 in availableCapabilityIds: + capability["hvacActionCapabilityId"] = 181 + if modelInfos["type"] == CozytouchDeviceType.GAZ_BOILER: capability["name"] = "central_heating" capability["icon"] = "mdi:radiator" @@ -44,21 +63,33 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["targetCoolCapabilityId"] = 177 capability["lowestCoolValueCapabilityId"] = 162 capability["highestCoolValueCapabilityId"] = 163 - capability["activityCapabilityId"] = 100506 - capability["ecoCapabilityId"] = 100507 - capability["boostCapabilityId"] = 100505 + if 100506 in availableCapabilityIds: + capability["activityCapabilityId"] = 100506 + if ( + modelInfos.get("ecoModeAvailable", True) + and 100507 in availableCapabilityIds + ): + capability["ecoCapabilityId"] = 100507 + if 100505 in availableCapabilityIds: + capability["boostCapabilityId"] = 100505 elif modelInfos["type"] == CozytouchDeviceType.HEAT_PUMP: if capabilityId in (1, 7): capability["name"] = "heat_pump_z1" capability["targetCapabilityId"] = 17 - if modelInfos.get("currentTemperatureAvailableZ1", True): + if ( + modelInfos.get("currentTemperatureAvailableZ1", True) + and 117 in availableCapabilityIds + ): capability["currentValueCapabilityId"] = 117 else: capability["currentValueCapabilityId"] = None else: capability["name"] = "heat_pump_z2" capability["targetCapabilityId"] = 18 - if modelInfos.get("currentTemperatureAvailableZ2", True): + if ( + modelInfos.get("currentTemperatureAvailableZ2", True) + and 118 in availableCapabilityIds + ): capability["currentValueCapabilityId"] = 118 else: capability["currentValueCapabilityId"] = None @@ -74,10 +105,13 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["type"] = "climate" capability["category"] = "sensor" - if "fanModes" in modelInfos: + if "fanModes" in modelInfos and 100801 in availableCapabilityIds: capability["fanModeCapabilityId"] = 100801 - if modelInfos.get("quietModeAvailable", False): + if ( + modelInfos.get("quietModeAvailable", False) + and 100802 in availableCapabilityIds + ): capability["quietModeCapabilityId"] = 100802 if modelInfos.get("overrideModeAvailable", True): @@ -86,9 +120,11 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["progOverrideTotalTimeCapabilityId"] = 158 capability["progOverrideTimeCapabilityId"] = 159 - if "swingModes" in modelInfos: + if "swingModes" in modelInfos and 100803 in availableCapabilityIds: capability["swingModeCapabilityId"] = 100803 - capability["swingOnCapabilityId"] = 100804 + + if 100804 in availableCapabilityIds: + capability["swingOnCapabilityId"] = 100804 elif capabilityId == 19: capability["name"] = "temperature_setpoint" @@ -362,6 +398,15 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["icon"] = "mdi:radio-tower" elif capabilityId == 172: + # Absence setpoint. Only the heating products act on it. An air + # conditioner reports it and stores what is written, but never reads it + # back: absence there stops the units until the return date, and the + # weekly program keeps driving 40 and 177 throughout. Exposing a number + # nothing honours would promise a setting the Cozytouch app does not + # even offer on this hardware. + if not modelInfos.get("awayModeTemperatureAvailable", True): + return {} + capability["name"] = "away_mode_temperature" capability["type"] = "temperature_adjustment_number" capability["category"] = "sensor" @@ -610,6 +655,15 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["category"] = "diag" capability["icon"] = "mdi:tag" + elif capabilityId == 100261: + # Per-room mirror of the hub's away mode flag (152). The room units carry + # it alongside the absence window in 100260, which stays unmapped: the + # window is written for the whole setup from the hub, not room by room. + capability["name"] = "away_mode" + capability["type"] = "binary" + capability["category"] = "sensor" + capability["icon"] = "mdi:airplane" + elif capabilityId == 100402: capability["name"] = "number_of_hours_burner" capability["type"] = "int" @@ -638,6 +692,12 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["icon"] = "mdi:account" elif capabilityId == 100507: + # Same story as the absence setpoint in 172: the air conditioners report + # eco mode without the Cozytouch app ever offering it. Reported is not + # supported, so let the model table decide. + if not modelInfos.get("ecoModeAvailable", True): + return {} + capability["name"] = "eco_mode" capability["type"] = "switch" capability["category"] = "sensor" diff --git a/custom_components/cozytouch/climate.py b/custom_components/cozytouch/climate.py index 8143a4d..df2bbc6 100644 --- a/custom_components/cozytouch/climate.py +++ b/custom_components/cozytouch/climate.py @@ -7,6 +7,7 @@ from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.components.climate.const import ( @@ -33,6 +34,18 @@ PRESET_PROG = "prog" PRESET_OVERRIDE = "override" +# The effective mode capability uses the same value scale as the requested mode, +# so it is read through the model's HVACModes and then translated. HVACMode.AUTO +# has no HVACAction counterpart on purpose: a system in auto is really heating, +# cooling or idle, and guessing which would be worse than reporting nothing. +HVAC_ACTIONS = { + HVACMode.OFF: HVACAction.OFF, + HVACMode.COOL: HVACAction.COOLING, + HVACMode.HEAT: HVACAction.HEATING, + HVACMode.DRY: HVACAction.DRYING, + HVACMode.FAN_ONLY: HVACAction.FAN, +} + # config flow setup async def async_setup_entry( @@ -193,6 +206,16 @@ def _handle_coordinator_update(self) -> None: if currentMode in HVACModes: self._attr_hvac_mode = HVACModes[currentMode] + # Effective mode, which can differ from the requested one: on a zoned + # install only the master picks the mode, so a slave keeps its own + # request here while actually running whatever the master imposes. + actionId = self._capability.get("hvacActionCapabilityId", None) + if actionId: + actionRaw = self.coordinator.get_capability_value(actionId) + if actionRaw is not None: + actionMode = HVACModes.get(int(actionRaw), None) + self._attr_hvac_action = HVAC_ACTIONS.get(actionMode, None) + # Target value if self._attr_hvac_mode in ( HVACMode.OFF, diff --git a/custom_components/cozytouch/hub.py b/custom_components/cozytouch/hub.py index cacbc94..0c745fa 100644 --- a/custom_components/cozytouch/hub.py +++ b/custom_components/cozytouch/hub.py @@ -428,11 +428,15 @@ def get_capabilities_for_device(self, deviceId: int | None = None): for dev in self._devices: if dev["deviceId"] == deviceId: modelInfos = get_model_infos(dev["modelId"]) + availableCapabilityIds = { + cap["capabilityId"] for cap in dev["capabilities"] + } for capability in dev["capabilities"]: capability_infos = get_capability_infos( modelInfos, capability["capabilityId"], capability["value"], + availableCapabilityIds, ) if capability_infos is None and self._create_unknown: @@ -461,12 +465,6 @@ def get_capabilities_for_device(self, deviceId: int | None = None): return capabilities - def get_capability_infos( - self, modelId: int, capabilityId: int, capabilityValue: str - ): - """Get capability infos.""" - return get_capability_infos(modelId, capabilityId, capabilityValue) - def get_capability_value( self, capabilityId: int, defaultIfNotExist: str | None = "0" ): diff --git a/custom_components/cozytouch/model.py b/custom_components/cozytouch/model.py index 862b9bd..9470f65 100644 --- a/custom_components/cozytouch/model.py +++ b/custom_components/cozytouch/model.py @@ -14,6 +14,8 @@ * fanModes : list of value/mode pairs * swingModes : list of value/mode pairs * quietModeAvailable : enable quiet mode availability (default : False) + * awayModeTemperatureAvailable : enable the absence setpoint (default : True) + * ecoModeAvailable : enable eco mode availability (default : True) """ # noqa: D205 @@ -181,6 +183,7 @@ def get_model_infos(modelId: int, zoneName: str | None = None): elif modelId == 556: modelInfos["name"] = "Naviclim Hub" modelInfos["type"] = CozytouchDeviceType.HUB + modelInfos["awayModeTemperatureAvailable"] = False modelInfos["HVACModes"] = { 0: HVACMode.OFF, } @@ -191,7 +194,16 @@ def get_model_infos(modelId: int, zoneName: str | None = None): modelInfos["HVACModes"] = { 0: HVACMode.OFF, } - + + elif modelId == 1758: + # AC gateway, drives the same 557-561 units as the 556 Naviclim hub + modelInfos["name"] = "HUB Navizone" + modelInfos["type"] = CozytouchDeviceType.HUB + modelInfos["awayModeTemperatureAvailable"] = False + modelInfos["HVACModes"] = { + 0: HVACMode.OFF, + } + elif (modelId >= 557 and modelId <= 561) or modelId == 1734: name = "Air Conditioner " if zoneName is not None: @@ -202,8 +214,14 @@ def get_model_infos(modelId: int, zoneName: str | None = None): modelInfos["name"] = name + "(#" + str(modelId - 1733) + ")" modelInfos["type"] = CozytouchDeviceType.AC - modelInfos["currentTemperatureAvailable"] = False modelInfos["quietModeAvailable"] = True + modelInfos["awayModeTemperatureAvailable"] = False + + # The room units behind a Naviclim/Navizone hub report 100507, but the + # Cozytouch app offers no eco mode for them anywhere. 1734 is a separate + # product and is left alone, no report either way on that one. + if modelId <= 561: + modelInfos["ecoModeAvailable"] = False modelInfos["fanModes"] = { 1: FAN_LOW,