diff --git a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json index 74e9bface..0ccc3be31 100644 --- a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json +++ b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json @@ -388,13 +388,15 @@ "hvac_action": null, "hvac_mode": "off", "preset_mode": "none", - "fan_mode": "auto", + "fan_mode": null, "max_temp": 30.0, "min_temp": 16.0, "supported_features": 395, "fan_modes": [ - "auto", - "on" + "low", + "medium", + "high", + "auto" ], "preset_modes": [], "hvac_modes": [ diff --git a/tests/data/devices/enktro-acmidea.json b/tests/data/devices/enktro-acmidea.json index 45be58544..ef38f36d8 100644 --- a/tests/data/devices/enktro-acmidea.json +++ b/tests/data/devices/enktro-acmidea.json @@ -325,8 +325,10 @@ "min_temp": 17.0, "supported_features": 395, "fan_modes": [ - "auto", - "on" + "low", + "medium", + "high", + "auto" ], "preset_modes": [], "hvac_modes": [ diff --git a/tests/test_climate.py b/tests/test_climate.py index 33aaa2114..d8f3f6a92 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -6,7 +6,7 @@ from collections.abc import Iterator import logging from typing import Any -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch import zoneinfo from freezegun import freeze_time @@ -17,7 +17,7 @@ import zhaquirks.tuya.ts0601_trv import zigpy.profiles import zigpy.zcl.clusters -from zigpy.zcl.clusters.hvac import Thermostat +from zigpy.zcl.clusters.hvac import FanMode, FanModeSequence, Thermostat import zigpy.zcl.foundation as zcl_f from tests.common import ( @@ -46,7 +46,7 @@ Thermostat as ThermostatEntity, ZehnderThermostat, ) -from zha.application.platforms.climate.const import FanState +from zha.application.platforms.climate.const import SEQ_FAN_MODES, FanState, HVACMode from zha.application.platforms.number import NumberConfigurationEntity from zha.application.platforms.sensor import ( Sensor, @@ -55,7 +55,7 @@ ) from zha.const import STATE_CHANGED from zha.exceptions import ZHAException -from zha.quirks import DeviceMatch, DeviceRegistry, ModelInfo +from zha.quirks import THERMOSTAT_FAN_ONLY_HVAC, DeviceMatch, DeviceRegistry, ModelInfo from zha.zigbee.device import Device _LOGGER = logging.getLogger(__name__) @@ -1300,6 +1300,59 @@ 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 + ) + + # Patch `fan_modes` to include a string that is intentionally absent from + # `FAN_MODE_TO_ZCL` so the defensive `.get(...) is None` branch in + # `async_set_fan_mode` is exercised (the earlier "mode not in fan_modes" + # rejection would otherwise short-circuit it). + with patch.object( + ThermostatEntity, + "fan_modes", + new_callable=PropertyMock, + return_value=["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, ): @@ -1325,6 +1378,204 @@ async def test_set_fan_mode( assert fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 5} +@pytest.mark.parametrize( + ("sequence", "expected_modes"), + ( + (FanModeSequence.Low_Med_High, [FanState.LOW, FanState.MEDIUM, FanState.HIGH]), + (FanModeSequence.Low_High, [FanState.LOW, FanState.HIGH]), + ( + FanModeSequence.Low_Med_High_Auto, + [FanState.LOW, FanState.MEDIUM, FanState.HIGH, FanState.AUTO], + ), + (FanModeSequence.Low_High_Auto, [FanState.LOW, FanState.HIGH, FanState.AUTO]), + (FanModeSequence.On_Auto, [FanState.ON, FanState.AUTO]), + (0xFF, [FanState.AUTO, FanState.ON]), # unknown sequence → fallback + ), +) +async def test_fan_modes_from_fan_mode_sequence( + zha_gateway: Gateway, + sequence: int, + expected_modes: list[str], +): + """fan_modes must be derived from the reported fan_mode_sequence.""" + 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 + ) + + await send_attributes_report( + zha_gateway, fan_cluster, {"fan_mode_sequence": sequence} + ) + await zha_gateway.async_block_till_done(wait_background_tasks=True) + + assert entity.fan_modes == expected_modes + # Sanity-check against the constant table for known sequences + if sequence in SEQ_FAN_MODES: + assert entity.fan_modes == SEQ_FAN_MODES[sequence] + + +@pytest.mark.parametrize( + ("zcl_mode", "expected"), + ( + (FanMode.Low, FanState.LOW), + (FanMode.Medium, FanState.MEDIUM), + (FanMode.High, FanState.HIGH), + (FanMode.Auto, FanState.AUTO), + ), +) +async def test_fan_mode_from_fan_cluster_report( + zha_gateway: Gateway, + zcl_mode: FanMode, + expected: str, +): + """fan_mode must reflect the Fan cluster attribute after a report.""" + 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 + ) + + # Sequence that includes low/medium/high/auto so every tested value is in range + await send_attributes_report( + zha_gateway, + fan_cluster, + {"fan_mode_sequence": FanModeSequence.Low_Med_High_Auto}, + ) + await send_attributes_report(zha_gateway, fan_cluster, {"fan_mode": zcl_mode}) + await zha_gateway.async_block_till_done(wait_background_tasks=True) + + assert entity.state.fan_mode == expected + + +@pytest.mark.parametrize( + ("mode", "zcl_value"), + ( + (FanState.LOW, FanMode.Low), + (FanState.MEDIUM, FanMode.Medium), + (FanState.HIGH, FanMode.High), + ), +) +async def test_set_fan_mode_low_medium_high( + zha_gateway: Gateway, + mode: str, + zcl_value: FanMode, +): + """Setting low/medium/high must write the corresponding FanMode ZCL value.""" + 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 + ) + + await send_attributes_report( + zha_gateway, + fan_cluster, + {"fan_mode_sequence": FanModeSequence.Low_Med_High_Auto}, + ) + await zha_gateway.async_block_till_done(wait_background_tasks=True) + + await entity.async_set_fan_mode(mode) + await zha_gateway.async_block_till_done() + + assert fan_cluster.write_attributes.await_count == 1 + assert fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": zcl_value} + + +async def test_fan_mode_state_emission_on_fan_cluster_report( + zha_gateway: Gateway, +): + """A fan_mode report on the Fan cluster must emit STATE_CHANGED.""" + 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 + ) + + await send_attributes_report( + zha_gateway, + fan_cluster, + {"fan_mode_sequence": FanModeSequence.Low_Med_High_Auto}, + ) + await zha_gateway.async_block_till_done(wait_background_tasks=True) + + subscriber = MagicMock() + entity.on_event(STATE_CHANGED, subscriber) + + await send_attributes_report(zha_gateway, fan_cluster, {"fan_mode": FanMode.High}) + await zha_gateway.async_block_till_done(wait_background_tasks=True) + + assert subscriber.call_count >= 1 + assert entity.state.fan_mode == FanState.HIGH + + +async def test_fan_mode_out_of_range_returns_none( + zha_gateway: Gateway, +): + """A reported fan_mode outside fan_modes must return None, not an orphan value.""" + 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 + ) + + # Sequence without "on"; report FanMode.On (0x04) which is outside the list + await send_attributes_report( + zha_gateway, + fan_cluster, + {"fan_mode_sequence": FanModeSequence.Low_Med_High}, + ) + await send_attributes_report(zha_gateway, fan_cluster, {"fan_mode": FanMode.On}) + await zha_gateway.async_block_till_done(wait_background_tasks=True) + + assert entity.fan_modes == [FanState.LOW, FanState.MEDIUM, FanState.HIGH] + assert entity.state.fan_mode is None + + +async def test_fan_mode_off_mapped_correctly( + zha_gateway: Gateway, +): + """FanMode.Off must not fall through to 'auto'.""" + 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 + ) + + await send_attributes_report( + zha_gateway, + fan_cluster, + {"fan_mode_sequence": FanModeSequence.On_Auto}, + ) + await send_attributes_report(zha_gateway, fan_cluster, {"fan_mode": FanMode.Off}) + await zha_gateway.async_block_till_done(wait_background_tasks=True) + + # Off maps to FAN_OFF which is not in On_Auto sequence → None, never "auto" + assert entity.state.fan_mode is None + assert entity.state.fan_mode != FanState.AUTO + + +async def test_fan_mode_smart_returns_none( + zha_gateway: Gateway, +): + """FanMode.Smart has no HA equivalent and must return None.""" + 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 + ) + + await send_attributes_report( + zha_gateway, + fan_cluster, + {"fan_mode_sequence": FanModeSequence.Low_Med_High_Auto}, + ) + await send_attributes_report(zha_gateway, fan_cluster, {"fan_mode": FanMode.Smart}) + await zha_gateway.async_block_till_done(wait_background_tasks=True) + + assert entity.state.fan_mode is None + + async def test_set_moes_preset(zha_gateway: Gateway): """Test setting preset for moes trv.""" diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index 31acebd1b..3386ec587 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -19,7 +19,6 @@ ) from zigpy.zcl.clusters.hvac import ( Fan as FanCluster, - FanMode, RunningState, SystemMode, Thermostat as ThermostatCluster, @@ -40,18 +39,22 @@ ATTR_OCCP_COOL_SETPT, ATTR_OCCP_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, ZCL_TEMP, + ZCL_TO_FAN_MODE, ClimateEntityFeature, HVACAction, HVACMode, Preset, ) from zha.decorators import periodic +from zha.quirks import THERMOSTAT_FAN_ONLY_HVAC from zha.units import UnitOfTemperature if TYPE_CHECKING: @@ -546,17 +549,21 @@ def recompute_capabilities(self) -> None: def on_add(self) -> None: """Run when entity is added.""" super().on_add() - for event_type in ( - AttributeReadEvent, - AttributeReportedEvent, - AttributeUpdatedEvent, - AttributeWrittenEvent, - ): - self._on_remove_callbacks.append( - self._cluster.on_event( - event_type.event_type, self.handle_attribute_updated + clusters = [self._cluster] + if self._fan_cluster is not None: + clusters.append(self._fan_cluster) + for cluster in clusters: + for event_type in ( + AttributeReadEvent, + AttributeReportedEvent, + AttributeUpdatedEvent, + AttributeWrittenEvent, + ): + self._on_remove_callbacks.append( + cluster.on_event( + event_type.event_type, self.handle_attribute_updated + ) ) - ) @property def state(self) -> ThermostatState: @@ -598,6 +605,14 @@ def outdoor_temperature(self): @property def fan_mode(self) -> str | None: """Return current FAN mode.""" + if self._fan_cluster is not None: + current = self._fan_cluster.get(FanCluster.AttributeDefs.fan_mode.name) + if current is not None: + mode = ZCL_TO_FAN_MODE.get(current) + if mode is not None and mode in (self.fan_modes or ()): + return mode + return None + running_state = self._running_state if running_state is None: return FAN_AUTO @@ -610,12 +625,13 @@ def fan_mode(self) -> str | None: return FAN_ON return FAN_AUTO - @functools.cached_property + @property def fan_modes(self) -> list[str] | None: """Return supported FAN modes.""" if self._fan_cluster is None: return None - return [FAN_AUTO, FAN_ON] + seq = self._fan_cluster.get(FanCluster.AttributeDefs.fan_mode_sequence.name) + return SEQ_FAN_MODES.get(seq, [FAN_AUTO, FAN_ON]) @property def hvac_action(self) -> HVACAction | None: @@ -673,7 +689,14 @@ 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(self._ctrl_sequence_of_oper, [HVACMode.OFF]) + modes = SEQ_OF_OPERATION.get(self._ctrl_sequence_of_oper, [HVACMode.OFF]) + if ( + self._fan_cluster 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: @@ -804,10 +827,13 @@ 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 write_attributes_safe( - self._fan_cluster, {FanCluster.AttributeDefs.fan_mode.name: mode} + self._fan_cluster, {FanCluster.AttributeDefs.fan_mode.name: zcl_mode} ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: diff --git a/zha/application/platforms/climate/const.py b/zha/application/platforms/climate/const.py index 17fe4d946..c234ac0c9 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" @@ -141,6 +147,33 @@ 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] = { + FanMode.Off: FAN_OFF, + FanMode.Low: FAN_LOW, + FanMode.Medium: FAN_MEDIUM, + FanMode.High: FAN_HIGH, + FanMode.On: FAN_ON, + FanMode.Auto: FAN_AUTO, + # FanMode.Smart (0x06) deliberately omitted — ZCL R8 s6.4.2.1.2 notes it + # resolves to another mode based on occupancy; no direct HA equivalent. +} + RUNNING_MODE = { RunningMode.Off: HVACMode.OFF, RunningMode.Cool: HVACMode.COOL, diff --git a/zha/quirks.py b/zha/quirks.py index f297c89b8..c9f8df964 100644 --- a/zha/quirks.py +++ b/zha/quirks.py @@ -37,6 +37,7 @@ BEGA_LIGHT_SWITCHABLE_WHITE = "bega.light_switchable_white" SE_POLL_SUMMATION = "se_poll_summation" SIREN_BASIC = "siren_basic" +THERMOSTAT_FAN_ONLY_HVAC = "thermostat_fan_only_hvac" class ModelInfo(NamedTuple):