diff --git a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json index d6dae5ee6..7bae33dcb 100644 --- a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json +++ b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json @@ -435,8 +435,10 @@ "min_temp": 16.0, "supported_features": 395, "fan_modes": [ - "auto", - "on" + "low", + "medium", + "high", + "auto" ], "preset_modes": [], "hvac_modes": [ @@ -457,7 +459,7 @@ "hvac_action": null, "hvac_mode": "off", "preset_mode": "none", - "fan_mode": "auto", + "fan_mode": "on", "system_mode": "[0]/off", "occupancy": null, "occupied_cooling_setpoint": 2600, diff --git a/tests/data/devices/centralite-systems-3156105.json b/tests/data/devices/centralite-systems-3156105.json index 1575e05f3..9f388e4ff 100644 --- a/tests/data/devices/centralite-systems-3156105.json +++ b/tests/data/devices/centralite-systems-3156105.json @@ -496,8 +496,8 @@ "min_temp": 7.0, "supported_features": 393, "fan_modes": [ - "auto", - "on" + "on", + "auto" ], "preset_modes": [], "hvac_modes": [ diff --git a/tests/data/devices/enktro-acmidea.json b/tests/data/devices/enktro-acmidea.json index 0ac2e7d26..68de46272 100644 --- a/tests/data/devices/enktro-acmidea.json +++ b/tests/data/devices/enktro-acmidea.json @@ -367,8 +367,10 @@ "min_temp": 17.0, "supported_features": 395, "fan_modes": [ - "auto", - "on" + "low", + "medium", + "high", + "auto" ], "preset_modes": [], "hvac_modes": [ diff --git a/tests/data/devices/zen-within-zen-01.json b/tests/data/devices/zen-within-zen-01.json index 347a53076..b877fe88a 100644 --- a/tests/data/devices/zen-within-zen-01.json +++ b/tests/data/devices/zen-within-zen-01.json @@ -461,8 +461,8 @@ "min_temp": 4.0, "supported_features": 395, "fan_modes": [ - "auto", - "on" + "on", + "auto" ], "preset_modes": [], "hvac_modes": [ diff --git a/tests/test_climate.py b/tests/test_climate.py index e23dd0420..931c72a51 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -45,7 +45,11 @@ Thermostat as ThermostatEntity, ZehnderThermostat, ) -from zha.application.platforms.climate.const import FanState +from zha.application.platforms.climate.const import ( + THERMOSTAT_FAN_ONLY_HVAC, + FanState, + HVACMode, +) from zha.application.platforms.number import NumberConfigurationEntity from zha.application.platforms.sensor import ( Sensor, @@ -1298,6 +1302,50 @@ async def test_set_fan_mode_not_supported( assert fan_cluster.write_attributes.await_count == 0 +async def test_set_fan_mode_no_zcl_mapping( + zha_gateway: Gateway, +): + """Test fan mode with no ZCL mapping is rejected.""" + device_climate_fan = await device_climate_mock(zha_gateway, CLIMATE_FAN) + fan_cluster = device_climate_fan.device.endpoints[1].fan + entity: ThermostatEntity = get_entity( + device_climate_fan, platform=Platform.CLIMATE, entity_type=ThermostatEntity + ) + + entity.__dict__["fan_modes"] = ["bogus"] + + await entity.async_set_fan_mode("bogus") + await zha_gateway.async_block_till_done() + assert fan_cluster.write_attributes.await_count == 0 + + +async def test_fan_only_hvac_mode_not_exposed_without_quirk_feature( + zha_gateway: Gateway, +): + """Fan cluster alone must not expose HVACMode.FAN_ONLY.""" + device_climate_fan = await device_climate_mock(zha_gateway, CLIMATE_FAN) + entity: ThermostatEntity = get_entity( + device_climate_fan, platform=Platform.CLIMATE, entity_type=ThermostatEntity + ) + + assert THERMOSTAT_FAN_ONLY_HVAC not in device_climate_fan.exposes_features + assert HVACMode.FAN_ONLY not in entity.hvac_modes + + +async def test_fan_only_hvac_mode_exposed_with_quirk_feature( + zha_gateway: Gateway, +): + """A quirk that opts in via exposes_features unlocks HVACMode.FAN_ONLY.""" + device_climate_fan = await device_climate_mock(zha_gateway, CLIMATE_FAN) + device_climate_fan.exposes_features.add(THERMOSTAT_FAN_ONLY_HVAC) + + entity: ThermostatEntity = get_entity( + device_climate_fan, platform=Platform.CLIMATE, entity_type=ThermostatEntity + ) + + assert HVACMode.FAN_ONLY in entity.hvac_modes + + async def test_set_fan_mode( zha_gateway: Gateway, ): diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index 4cc8d37a5..df73cef13 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -11,7 +11,6 @@ from zigpy.profiles import zha from zigpy.zcl.clusters.hvac import ( - FanMode, RunningState, SystemMode, Thermostat as ThermostatCluster, @@ -35,12 +34,16 @@ ATTR_UNOCCP_COOL_SETPT, ATTR_UNOCCP_HEAT_SETPT, FAN_AUTO, + FAN_MODE_TO_ZCL, FAN_ON, HVAC_MODE_2_SYSTEM, PRECISION_TENTHS, + SEQ_FAN_MODES, SEQ_OF_OPERATION, SYSTEM_MODE_2_HVAC, + THERMOSTAT_FAN_ONLY_HVAC, ZCL_TEMP, + ZCL_TO_FAN_MODE, ClimateEntityFeature, HVACAction, HVACMode, @@ -332,9 +335,12 @@ def outdoor_temperature(self): @property def fan_mode(self) -> str | None: """Return current FAN mode.""" + if self._fan_cluster_handler is not None: + current = self._fan_cluster_handler.fan_mode + if current is not None: + return ZCL_TO_FAN_MODE.get(current, FAN_AUTO) if self._thermostat_cluster_handler.running_state is None: return FAN_AUTO - if self._thermostat_cluster_handler.running_state & ( RunningState.Fan_State_On | RunningState.Fan_2nd_Stage_On @@ -348,7 +354,8 @@ def fan_modes(self) -> list[str] | None: """Return supported FAN modes.""" if not self._fan_cluster_handler: return None - return [FAN_AUTO, FAN_ON] + seq = self._fan_cluster_handler.fan_mode_sequence + return SEQ_FAN_MODES.get(seq, [FAN_ON, FAN_AUTO]) @property def hvac_action(self) -> HVACAction | None: @@ -409,9 +416,16 @@ def hvac_mode(self) -> HVACMode | None: @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available HVAC operation modes.""" - return SEQ_OF_OPERATION.get( + modes = SEQ_OF_OPERATION.get( self._thermostat_cluster_handler.ctrl_sequence_of_oper, [HVACMode.OFF] ) + if ( + self._fan_cluster_handler is not None + and THERMOSTAT_FAN_ONLY_HVAC in self._device.exposes_features + and HVACMode.FAN_ONLY not in modes + ): + modes = [*modes, HVACMode.FAN_ONLY] + return modes @property def preset_mode(self) -> str: @@ -538,9 +552,12 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: self.warning("Unsupported '%s' fan mode", fan_mode) return - mode = FanMode.On if fan_mode == FAN_ON else FanMode.Auto + zcl_mode = FAN_MODE_TO_ZCL.get(fan_mode) + if zcl_mode is None: + self.warning("No ZCL mapping for fan mode '%s'", fan_mode) + return - await self._fan_cluster_handler.async_set_speed(mode) + await self._fan_cluster_handler.async_set_speed(zcl_mode) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" diff --git a/zha/application/platforms/climate/const.py b/zha/application/platforms/climate/const.py index 17fe4d946..de8ca5e9f 100644 --- a/zha/application/platforms/climate/const.py +++ b/zha/application/platforms/climate/const.py @@ -3,7 +3,13 @@ from enum import IntFlag, StrEnum from typing import Final -from zigpy.zcl.clusters.hvac import ControlSequenceOfOperation, RunningMode, SystemMode +from zigpy.zcl.clusters.hvac import ( + ControlSequenceOfOperation, + FanMode, + FanModeSequence, + RunningMode, + SystemMode, +) ATTR_SYS_MODE: Final[str] = "system_mode" ATTR_FAN_MODE: Final[str] = "fan_mode" @@ -22,6 +28,12 @@ ATTR_TARGET_TEMP_LOW: Final[str] = "target_temp_low" ATTR_TEMPERATURE: Final[str] = "temperature" +# Opt-in quirk feature id: when present in ``device.exposes_features``, the +# thermostat entity will expose ``HVACMode.FAN_ONLY``. The presence of the Fan +# cluster (0x0202) alone is not sufficient, because many devices advertise the +# cluster without actually implementing ``SystemMode.Fan_only`` (0x07). +THERMOSTAT_FAN_ONLY_HVAC: Final[str] = "thermostat_fan_only_hvac" + PRECISION_TENTHS: Final[float] = 0.1 # Possible fan state @@ -141,6 +153,24 @@ class HVACAction(StrEnum): PREHEATING = "preheating" +SEQ_FAN_MODES: dict[int, list[str]] = { + FanModeSequence.Low_Med_High: [FAN_LOW, FAN_MEDIUM, FAN_HIGH], + FanModeSequence.Low_High: [FAN_LOW, FAN_HIGH], + FanModeSequence.Low_Med_High_Auto: [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO], + FanModeSequence.Low_High_Auto: [FAN_LOW, FAN_HIGH, FAN_AUTO], + FanModeSequence.On_Auto: [FAN_ON, FAN_AUTO], +} + +FAN_MODE_TO_ZCL: dict[str, FanMode] = { + FAN_LOW: FanMode.Low, + FAN_MEDIUM: FanMode.Medium, + FAN_HIGH: FanMode.High, + FAN_ON: FanMode.On, + FAN_AUTO: FanMode.Auto, +} + +ZCL_TO_FAN_MODE: dict[int, str] = {v: k for k, v in FAN_MODE_TO_ZCL.items()} + RUNNING_MODE = { RunningMode.Off: HVACMode.OFF, RunningMode.Cool: HVACMode.COOL,