From 771d25d10068c7d46684cb4bac4794d038f2e8e1 Mon Sep 17 00:00:00 2001 From: Gilles DOFFE Date: Mon, 21 Oct 2024 23:53:36 +0200 Subject: [PATCH 1/2] Adjust system mode behavior for Acova (Zehnder) heaters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses an issue where SystemMode.AUTO for Acova (Zehnder) heaters behaves differently from the HA standard. For this manufacturer, SystemMode.AUTO allows control of the heater via Zigbee, while SystemMode.HEAT puts the heater in manual mode. As a result, the conversion tables in ZHA climate constants (HVACMode) are incorrect for this device. The mapping for SystemMode.AUTO has been updated to correspond to HVACMode.HEAT instead of HVACMode.HEAT_COOL. Signed-off-by: Gilles DOFFE Co-authored-by: Matthéo PERELLE --- zha/application/platforms/climate/__init__.py | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index fde530d6..8918e79b 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -589,6 +589,72 @@ class ZenWithinThermostat(Thermostat): """Zen Within Thermostat implementation.""" +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + manufacturers={"ZEHNDER GROUP VAUX ANDIGNY ", "ZEHNDER GROUP VAUX ANDIGNY"}, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class ZehnderThermostat(Thermostat): + """Zehnder thermostat to adapt AUTO mode behavior.""" + + ZEHNDER_HVAC_MODE_2_SYSTEM = { + HVACMode.OFF: SystemMode.Off, + HVACMode.HEAT: SystemMode.Auto, + } + + ZEHNDER_SYSTEM_MODE_2_HVAC = { + SystemMode.Off: HVACMode.OFF, + SystemMode.Auto: HVACMode.HEAT, + } + + hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target operation mode.""" + if hvac_mode not in self.hvac_modes: + self.warning( + "can't set '%s' mode. Supported modes are: %s", + hvac_mode, + self.hvac_modes, + ) + return + + if await self._thermostat_cluster_handler.async_set_operation_mode( + ZehnderThermostat.ZEHNDER_HVAC_MODE_2_SYSTEM[hvac_mode] + ): + self.maybe_emit_state_changed_event() + + @property + def current_temperature(self): + """Force no current temperature.""" + return None + + @property + def state(self) -> dict[str, Any]: + """Get the state of the lock.""" + thermostat = self._thermostat_cluster_handler + system_mode = ZehnderThermostat.ZEHNDER_SYSTEM_MODE_2_HVAC.get( + thermostat.system_mode, "unknown" + ) + + response = super().state + + response[ATTR_SYS_MODE] = ( + f"[{thermostat.system_mode}]/{system_mode}" + if self.hvac_mode is not None + else None + ) + + return response + + @property + def hvac_mode(self) -> HVACMode | None: + """Return HVAC operation mode.""" + return ZehnderThermostat.ZEHNDER_SYSTEM_MODE_2_HVAC.get( + self._thermostat_cluster_handler.system_mode + ) + + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, aux_cluster_handlers=CLUSTER_HANDLER_FAN, From e978f41017c95984b960f077ee6dc8c026f61506 Mon Sep 17 00:00:00 2001 From: Gilles DOFFE Date: Wed, 30 Oct 2024 16:50:18 +0100 Subject: [PATCH 2/2] Add test for Acova (Zehnder) thermostat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Gilles DOFFE Co-authored-by: Matthéo PERELLE --- tests/test_climate.py | 127 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/tests/test_climate.py b/tests/test_climate.py index c8b859ae..0af16fd2 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -42,6 +42,7 @@ HVAC_MODE_2_SYSTEM, SEQ_OF_OPERATION, Thermostat as ThermostatEntity, + ZehnderThermostat, ) from zha.application.platforms.climate.const import FanState from zha.application.platforms.sensor import ( @@ -120,6 +121,19 @@ } } +CLIMATE_ZEHNDER = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + ], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Identify.cluster_id], + } +} + CLIMATE_MOES = { 1: { SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, @@ -168,6 +182,7 @@ MANUF_SINOPE = "Sinope Technologies" MANUF_ZEN = "Zen Within" +MANUF_ZEHNDER = "ZEHNDER GROUP VAUX ANDIGNY " MANUF_MOES = "_TZE200_ckud7u2l" MANUF_BECA = "_TZE200_b6wax7g0" MANUF_ZONNSMART = "_TZE200_hue3yfsn" @@ -479,6 +494,118 @@ async def test_climate_hvac_action_running_state_zen( assert sensor_entity.state["state"] == "idle" +async def test_climate_hvac_action_running_state_zehnder( + zha_gateway: Gateway, +): + """Test Zehnder hvac action via running state.""" + device_climate_zehnder = await device_climate_mock( + zha_gateway, CLIMATE_ZEHNDER, manuf=MANUF_ZEHNDER + ) + + thrm_cluster = device_climate_zehnder.device.endpoints[1].thermostat + + entity: ThermostatEntity = get_entity( + device_climate_zehnder, platform=Platform.CLIMATE, entity_type=ThermostatEntity + ) + + assert entity.state["hvac_action"] is None + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_2nd_Stage_On} + ) + assert entity.state["hvac_action"] == "cooling" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_State_On} + ) + assert entity.state["hvac_action"] == "fan" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_2nd_Stage_On} + ) + assert entity.state["hvac_action"] == "heating" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_2nd_Stage_On} + ) + assert entity.state["hvac_action"] == "fan" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_State_On} + ) + assert entity.state["hvac_action"] == "cooling" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_3rd_Stage_On} + ) + assert entity.state["hvac_action"] == "fan" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_State_On} + ) + assert entity.state["hvac_action"] == "heating" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Idle} + ) + assert entity.state["hvac_action"] == "off" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x001C: Thermostat.SystemMode.Heat} + ) + assert entity.state["hvac_action"] == "idle" + + +@pytest.mark.parametrize( + "hvac_mode, sys_mode", + ( + ("heat", Thermostat.SystemMode.Auto), + ("off", Thermostat.SystemMode.Off), + ("heat_cool", None), + ), +) +async def test_set_hvac_mode_zehnder( + zha_gateway: Gateway, + hvac_mode, + sys_mode, +): + """Test setting hvac mode.""" + device_climate_zehnder = await device_climate_mock( + zha_gateway, CLIMATE_ZEHNDER, manuf=MANUF_ZEHNDER + ) + + thrm_cluster = device_climate_zehnder.device.endpoints[1].thermostat + entity: ThermostatEntity = get_entity( + device_climate_zehnder, platform=Platform.CLIMATE, entity_type=ZehnderThermostat + ) + + assert entity.state["hvac_mode"] == "off" + + await entity.async_set_hvac_mode(hvac_mode) + await zha_gateway.async_block_till_done() + + if sys_mode is not None: + assert entity.state["hvac_mode"] == hvac_mode + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == { + "system_mode": sys_mode + } + else: + assert thrm_cluster.write_attributes.call_count == 0 + assert entity.state["hvac_mode"] == "off" + + # turn off + thrm_cluster.write_attributes.reset_mock() + await entity.async_set_hvac_mode("off") + await zha_gateway.async_block_till_done() + + assert entity.state["hvac_mode"] == "off" + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == { + "system_mode": Thermostat.SystemMode.Off + } + + async def test_climate_hvac_action_pi_demand( zha_gateway: Gateway, ):