From d25a53f7953ef67e8c20c0ec80a1084c01d5c944 Mon Sep 17 00:00:00 2001 From: vmvarga Date: Thu, 12 Mar 2026 15:00:59 -0400 Subject: [PATCH 01/12] Fix thermostat fan modes to respect fanModeSequence and expose FAN_ONLY HVAC mode --- zha/application/platforms/climate/__init__.py | 24 +++++++++++++----- zha/application/platforms/climate/const.py | 25 ++++++++++++++++++- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index 4cc8d37a5..f61a5de90 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,11 +34,14 @@ 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, + ZCL_TO_FAN_MODE, ZCL_TEMP, ClimateEntityFeature, HVACAction, @@ -332,9 +334,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 +353,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 +415,12 @@ 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 HVACMode.FAN_ONLY not in modes: + modes = [*modes, HVACMode.FAN_ONLY] + return modes @property def preset_mode(self) -> str: @@ -538,9 +547,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..f3b744aa6 100644 --- a/zha/application/platforms/climate/const.py +++ b/zha/application/platforms/climate/const.py @@ -3,7 +3,12 @@ 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, + RunningMode, + SystemMode, +) ATTR_SYS_MODE: Final[str] = "system_mode" ATTR_FAN_MODE: Final[str] = "fan_mode" @@ -141,6 +146,24 @@ class HVACAction(StrEnum): PREHEATING = "preheating" +SEQ_FAN_MODES: dict[int, list[str]] = { + 0x00: [FAN_LOW, FAN_MEDIUM, FAN_HIGH], + 0x01: [FAN_LOW, FAN_HIGH], + 0x02: [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO], + 0x03: [FAN_LOW, FAN_HIGH, FAN_AUTO], + 0x04: [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, From 2886d25e8c036c721abe1411c624f08f6547c83a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:04:08 +0000 Subject: [PATCH 02/12] Apply pre-commit auto fixes --- zha/application/platforms/climate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index f61a5de90..80aef4cd8 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -41,8 +41,8 @@ SEQ_FAN_MODES, SEQ_OF_OPERATION, SYSTEM_MODE_2_HVAC, - ZCL_TO_FAN_MODE, ZCL_TEMP, + ZCL_TO_FAN_MODE, ClimateEntityFeature, HVACAction, HVACMode, From 23047280f18427a09f10ba3e44663f6642e07426 Mon Sep 17 00:00:00 2001 From: vmvarga Date: Thu, 12 Mar 2026 15:23:03 -0400 Subject: [PATCH 03/12] Update constants --- .../atlantic-group-adapter-zigbee-fujitsu.json | 11 +++++++---- tests/data/devices/centralite-systems-3156105.json | 7 ++++--- tests/data/devices/enktro-acmidea.json | 9 ++++++--- tests/data/devices/zen-within-zen-01.json | 7 ++++--- zha/application/platforms/climate/const.py | 11 ++++++----- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json index d6dae5ee6..28fa105b6 100644 --- a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json +++ b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json @@ -435,15 +435,18 @@ "min_temp": 16.0, "supported_features": 395, "fan_modes": [ - "auto", - "on" + "low", + "medium", + "high", + "auto" ], "preset_modes": [], "hvac_modes": [ "off", "heat_cool", "cool", - "heat" + "heat", + "fan_only" ] }, "state": { @@ -457,7 +460,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..1fd1ab1d2 100644 --- a/tests/data/devices/centralite-systems-3156105.json +++ b/tests/data/devices/centralite-systems-3156105.json @@ -496,14 +496,15 @@ "min_temp": 7.0, "supported_features": 393, "fan_modes": [ - "auto", - "on" + "on", + "auto" ], "preset_modes": [], "hvac_modes": [ "cool", "heat", - "off" + "off", + "fan_only" ] }, "state": { diff --git a/tests/data/devices/enktro-acmidea.json b/tests/data/devices/enktro-acmidea.json index 0ac2e7d26..b4bc2ce90 100644 --- a/tests/data/devices/enktro-acmidea.json +++ b/tests/data/devices/enktro-acmidea.json @@ -367,15 +367,18 @@ "min_temp": 17.0, "supported_features": 395, "fan_modes": [ - "auto", - "on" + "low", + "medium", + "high", + "auto" ], "preset_modes": [], "hvac_modes": [ "off", "heat_cool", "cool", - "heat" + "heat", + "fan_only" ] }, "state": { diff --git a/tests/data/devices/zen-within-zen-01.json b/tests/data/devices/zen-within-zen-01.json index 347a53076..dc1ed8c1a 100644 --- a/tests/data/devices/zen-within-zen-01.json +++ b/tests/data/devices/zen-within-zen-01.json @@ -461,15 +461,16 @@ "min_temp": 4.0, "supported_features": 395, "fan_modes": [ - "auto", - "on" + "on", + "auto" ], "preset_modes": [], "hvac_modes": [ "off", "heat_cool", "cool", - "heat" + "heat", + "fan_only" ] }, "state": { diff --git a/zha/application/platforms/climate/const.py b/zha/application/platforms/climate/const.py index f3b744aa6..4c4276ac7 100644 --- a/zha/application/platforms/climate/const.py +++ b/zha/application/platforms/climate/const.py @@ -6,6 +6,7 @@ from zigpy.zcl.clusters.hvac import ( ControlSequenceOfOperation, FanMode, + FanModeSequence, RunningMode, SystemMode, ) @@ -147,11 +148,11 @@ class HVACAction(StrEnum): SEQ_FAN_MODES: dict[int, list[str]] = { - 0x00: [FAN_LOW, FAN_MEDIUM, FAN_HIGH], - 0x01: [FAN_LOW, FAN_HIGH], - 0x02: [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO], - 0x03: [FAN_LOW, FAN_HIGH, FAN_AUTO], - 0x04: [FAN_ON, FAN_AUTO], + 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] = { From edfb2464b967c4590db97823b633fd39bd1e3c18 Mon Sep 17 00:00:00 2001 From: vmvarga Date: Fri, 13 Mar 2026 03:50:47 -0400 Subject: [PATCH 04/12] add test --- tests/test_climate.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_climate.py b/tests/test_climate.py index e23dd0420..e262a4527 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -1298,6 +1298,23 @@ 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_set_fan_mode( zha_gateway: Gateway, ): From 4e3737cedc7eb2486e304f6260b3a055aa90b3ed Mon Sep 17 00:00:00 2001 From: vmvarga Date: Mon, 20 Apr 2026 17:13:07 +0200 Subject: [PATCH 05/12] Opt-in fan only via quirks --- ...atlantic-group-adapter-zigbee-fujitsu.json | 3 +- .../devices/centralite-systems-3156105.json | 3 +- tests/data/devices/enktro-acmidea.json | 3 +- tests/data/devices/zen-within-zen-01.json | 3 +- tests/test_climate.py | 33 ++++++++++++++++++- zha/application/platforms/climate/__init__.py | 7 +++- zha/application/platforms/climate/const.py | 6 ++++ 7 files changed, 48 insertions(+), 10 deletions(-) diff --git a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json index 28fa105b6..7bae33dcb 100644 --- a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json +++ b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json @@ -445,8 +445,7 @@ "off", "heat_cool", "cool", - "heat", - "fan_only" + "heat" ] }, "state": { diff --git a/tests/data/devices/centralite-systems-3156105.json b/tests/data/devices/centralite-systems-3156105.json index 1fd1ab1d2..9f388e4ff 100644 --- a/tests/data/devices/centralite-systems-3156105.json +++ b/tests/data/devices/centralite-systems-3156105.json @@ -503,8 +503,7 @@ "hvac_modes": [ "cool", "heat", - "off", - "fan_only" + "off" ] }, "state": { diff --git a/tests/data/devices/enktro-acmidea.json b/tests/data/devices/enktro-acmidea.json index b4bc2ce90..68de46272 100644 --- a/tests/data/devices/enktro-acmidea.json +++ b/tests/data/devices/enktro-acmidea.json @@ -377,8 +377,7 @@ "off", "heat_cool", "cool", - "heat", - "fan_only" + "heat" ] }, "state": { diff --git a/tests/data/devices/zen-within-zen-01.json b/tests/data/devices/zen-within-zen-01.json index dc1ed8c1a..b877fe88a 100644 --- a/tests/data/devices/zen-within-zen-01.json +++ b/tests/data/devices/zen-within-zen-01.json @@ -469,8 +469,7 @@ "off", "heat_cool", "cool", - "heat", - "fan_only" + "heat" ] }, "state": { diff --git a/tests/test_climate.py b/tests/test_climate.py index e262a4527..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, @@ -1315,6 +1319,33 @@ async def test_set_fan_mode_no_zcl_mapping( 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 80aef4cd8..df73cef13 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -41,6 +41,7 @@ SEQ_FAN_MODES, SEQ_OF_OPERATION, SYSTEM_MODE_2_HVAC, + THERMOSTAT_FAN_ONLY_HVAC, ZCL_TEMP, ZCL_TO_FAN_MODE, ClimateEntityFeature, @@ -418,7 +419,11 @@ def hvac_modes(self) -> list[HVACMode]: modes = SEQ_OF_OPERATION.get( self._thermostat_cluster_handler.ctrl_sequence_of_oper, [HVACMode.OFF] ) - if self._fan_cluster_handler is not None and HVACMode.FAN_ONLY not in modes: + 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 diff --git a/zha/application/platforms/climate/const.py b/zha/application/platforms/climate/const.py index 4c4276ac7..de8ca5e9f 100644 --- a/zha/application/platforms/climate/const.py +++ b/zha/application/platforms/climate/const.py @@ -28,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 From 2dcb76708f7e2a3bc69c622a6fd269631f338940 Mon Sep 17 00:00:00 2001 From: vmvarga Date: Mon, 15 Jun 2026 20:17:14 +0200 Subject: [PATCH 06/12] move fan_only_hvac to zhaquirks --- tests/test_climate.py | 7 ++----- zha/application/platforms/climate/__init__.py | 3 +-- zha/application/platforms/climate/const.py | 6 ------ 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index a6642e1eb..0d147ed28 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -10,6 +10,7 @@ from freezegun import freeze_time import pytest +from zhaquirks.quirk_ids import THERMOSTAT_FAN_ONLY_HVAC import zhaquirks.sinope.thermostat from zhaquirks.sinope.thermostat import SinopeTechnologiesThermostatCluster import zhaquirks.tuya.ts0601_trv @@ -45,11 +46,7 @@ Thermostat as ThermostatEntity, ZehnderThermostat, ) -from zha.application.platforms.climate.const import ( - THERMOSTAT_FAN_ONLY_HVAC, - FanState, - HVACMode, -) +from zha.application.platforms.climate.const import FanState, HVACMode from zha.application.platforms.number import NumberConfigurationEntity from zha.application.platforms.sensor import ( Sensor, diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index 657425490..f31f69fcb 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -9,6 +9,7 @@ import functools from typing import TYPE_CHECKING, Any +from zhaquirks.quirk_ids import THERMOSTAT_FAN_ONLY_HVAC from zigpy.profiles import zha from zigpy.zcl import ( AttributeReadEvent, @@ -19,7 +20,6 @@ ) from zigpy.zcl.clusters.hvac import ( Fan as FanCluster, - FanMode, RunningState, SystemMode, Thermostat as ThermostatCluster, @@ -53,7 +53,6 @@ SEQ_FAN_MODES, SEQ_OF_OPERATION, SYSTEM_MODE_2_HVAC, - THERMOSTAT_FAN_ONLY_HVAC, ZCL_TEMP, ZCL_TO_FAN_MODE, ClimateEntityFeature, diff --git a/zha/application/platforms/climate/const.py b/zha/application/platforms/climate/const.py index de8ca5e9f..4c4276ac7 100644 --- a/zha/application/platforms/climate/const.py +++ b/zha/application/platforms/climate/const.py @@ -28,12 +28,6 @@ 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 From cb51c8523a046231f32e15811b2bb32de56bf2c8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:14:22 -0400 Subject: [PATCH 07/12] pre-commit --- tests/test_climate.py | 2 +- zha/application/platforms/climate/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index 53aea2202..73bce76dc 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -55,7 +55,7 @@ ) from zha.const import STATE_CHANGED from zha.exceptions import ZHAException -from zha.quirks import DeviceMatch, DeviceRegistry, ModelInfo, THERMOSTAT_FAN_ONLY_HVAC +from zha.quirks import THERMOSTAT_FAN_ONLY_HVAC, DeviceMatch, DeviceRegistry, ModelInfo from zha.zigbee.device import Device _LOGGER = logging.getLogger(__name__) diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index 8259121d1..75c17912e 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -9,7 +9,6 @@ import functools from typing import TYPE_CHECKING -from zha.quirks import THERMOSTAT_FAN_ONLY_HVAC from zigpy.profiles import zha from zigpy.zcl import ( AttributeReadEvent, @@ -55,6 +54,7 @@ Preset, ) from zha.decorators import periodic +from zha.quirks import THERMOSTAT_FAN_ONLY_HVAC from zha.units import UnitOfTemperature if TYPE_CHECKING: From 4db4310dcae945a512a9924d2c579791f4f3da43 Mon Sep 17 00:00:00 2001 From: vmvarga Date: Wed, 29 Jul 2026 09:43:11 +0200 Subject: [PATCH 08/12] Fix thermostat fan mode event wiring and ZCL mapping edge cases --- ...atlantic-group-adapter-zigbee-fujitsu.json | 2 +- ...centralite-systems-3156105-0x1418468c.json | 4 +- .../devices/zen-within-zen-01-0x0000021f.json | 4 +- tests/test_climate.py | 202 +++++++++++++++++- zha/application/platforms/climate/__init__.py | 31 +-- zha/application/platforms/climate/const.py | 11 +- 6 files changed, 234 insertions(+), 20 deletions(-) diff --git a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json index adcf6f4ed..0ccc3be31 100644 --- a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json +++ b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json @@ -388,7 +388,7 @@ "hvac_action": null, "hvac_mode": "off", "preset_mode": "none", - "fan_mode": "on", + "fan_mode": null, "max_temp": 30.0, "min_temp": 16.0, "supported_features": 395, diff --git a/tests/data/devices/centralite-systems-3156105-0x1418468c.json b/tests/data/devices/centralite-systems-3156105-0x1418468c.json index 64ad6de0b..d33809810 100644 --- a/tests/data/devices/centralite-systems-3156105-0x1418468c.json +++ b/tests/data/devices/centralite-systems-3156105-0x1418468c.json @@ -454,8 +454,8 @@ "min_temp": 7.0, "supported_features": 393, "fan_modes": [ - "on", - "auto" + "auto", + "on" ], "preset_modes": [], "hvac_modes": [ diff --git a/tests/data/devices/zen-within-zen-01-0x0000021f.json b/tests/data/devices/zen-within-zen-01-0x0000021f.json index 050c52b30..1624420be 100644 --- a/tests/data/devices/zen-within-zen-01-0x0000021f.json +++ b/tests/data/devices/zen-within-zen-01-0x0000021f.json @@ -419,8 +419,8 @@ "min_temp": 4.0, "supported_features": 395, "fan_modes": [ - "on", - "auto" + "auto", + "on" ], "preset_modes": [], "hvac_modes": [ diff --git a/tests/test_climate.py b/tests/test_climate.py index 73bce76dc..d8f3f6a92 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -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, HVACMode +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, @@ -1378,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 75c17912e..3386ec587 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -549,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: @@ -604,7 +608,10 @@ def fan_mode(self) -> str | None: if self._fan_cluster is not None: current = self._fan_cluster.get(FanCluster.AttributeDefs.fan_mode.name) if current is not None: - return ZCL_TO_FAN_MODE.get(current, FAN_AUTO) + 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: @@ -624,7 +631,7 @@ def fan_modes(self) -> list[str] | None: if self._fan_cluster is None: return None seq = self._fan_cluster.get(FanCluster.AttributeDefs.fan_mode_sequence.name) - return SEQ_FAN_MODES.get(seq, [FAN_ON, FAN_AUTO]) + return SEQ_FAN_MODES.get(seq, [FAN_AUTO, FAN_ON]) @property def hvac_action(self) -> HVACAction | None: diff --git a/zha/application/platforms/climate/const.py b/zha/application/platforms/climate/const.py index 4c4276ac7..c234ac0c9 100644 --- a/zha/application/platforms/climate/const.py +++ b/zha/application/platforms/climate/const.py @@ -163,7 +163,16 @@ class HVACAction(StrEnum): FAN_AUTO: FanMode.Auto, } -ZCL_TO_FAN_MODE: dict[int, str] = {v: k for k, v in FAN_MODE_TO_ZCL.items()} +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, From 1df4b11e46dea93cb7436d0ef5270dcf45fff3fa Mon Sep 17 00:00:00 2001 From: vmvarga Date: Mon, 10 Aug 2026 19:49:15 +0200 Subject: [PATCH 09/12] Fix On_Auto fan mode order and clamp running_state fallback --- tests/test_climate.py | 26 ++++++++++++++++++- zha/application/platforms/climate/__init__.py | 17 ++++++++---- zha/application/platforms/climate/const.py | 2 +- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index d8f3f6a92..fbfe120b9 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -1388,7 +1388,7 @@ async def test_set_fan_mode( [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]), + (FanModeSequence.On_Auto, [FanState.AUTO, FanState.ON]), (0xFF, [FanState.AUTO, FanState.ON]), # unknown sequence → fallback ), ) @@ -1532,6 +1532,30 @@ async def test_fan_mode_out_of_range_returns_none( assert entity.state.fan_mode is None +async def test_fan_mode_running_state_fallback_clamped( + zha_gateway: Gateway, +): + """Uncached fan_mode must not return auto/on outside fan_modes via running_state.""" + 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 with no auto/on; leave fan_mode uncached so the running_state + # heuristic would otherwise return FAN_AUTO. + await send_attributes_report( + zha_gateway, + fan_cluster, + {"fan_mode_sequence": FanModeSequence.Low_Med_High}, + ) + await zha_gateway.async_block_till_done(wait_background_tasks=True) + + assert fan_cluster.get("fan_mode") is None + 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, ): diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index 3386ec587..265ac092b 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -615,15 +615,22 @@ def fan_mode(self) -> str | None: running_state = self._running_state if running_state is None: - return FAN_AUTO - - if running_state & ( + mode = FAN_AUTO + elif running_state & ( RunningState.Fan_State_On | RunningState.Fan_2nd_Stage_On | RunningState.Fan_3rd_Stage_On ): - return FAN_ON - return FAN_AUTO + mode = FAN_ON + else: + mode = FAN_AUTO + + # Only clamp when the device exposes a concrete fan_modes list. Devices + # without a Fan cluster keep the historical running_state heuristic. + fan_modes = self.fan_modes + if fan_modes is None or mode in fan_modes: + return mode + return None @property def fan_modes(self) -> list[str] | None: diff --git a/zha/application/platforms/climate/const.py b/zha/application/platforms/climate/const.py index c234ac0c9..2ac91297b 100644 --- a/zha/application/platforms/climate/const.py +++ b/zha/application/platforms/climate/const.py @@ -152,7 +152,7 @@ class HVACAction(StrEnum): 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], + FanModeSequence.On_Auto: [FAN_AUTO, FAN_ON], } FAN_MODE_TO_ZCL: dict[str, FanMode] = { From 1fd4370bc76366b54cd8f3bd88b8c70da85e3443 Mon Sep 17 00:00:00 2001 From: vmvarga Date: Tue, 11 Aug 2026 09:39:08 +0200 Subject: [PATCH 10/12] Union reported fan mode into fan_modes for out-of-sequence devices --- ...atlantic-group-adapter-zigbee-fujitsu.json | 5 +++-- tests/test_climate.py | 19 ++++++++++++------- zha/application/platforms/climate/__init__.py | 11 ++++++++++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json index 0ccc3be31..ab6ae8e68 100644 --- a/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json +++ b/tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json @@ -388,7 +388,7 @@ "hvac_action": null, "hvac_mode": "off", "preset_mode": "none", - "fan_mode": null, + "fan_mode": "on", "max_temp": 30.0, "min_temp": 16.0, "supported_features": 395, @@ -396,7 +396,8 @@ "low", "medium", "high", - "auto" + "auto", + "on" ], "preset_modes": [], "hvac_modes": [ diff --git a/tests/test_climate.py b/tests/test_climate.py index fbfe120b9..9421839b0 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -1509,10 +1509,10 @@ async def test_fan_mode_state_emission_on_fan_cluster_report( assert entity.state.fan_mode == FanState.HIGH -async def test_fan_mode_out_of_range_returns_none( +async def test_fan_mode_outside_sequence_is_unioned( zha_gateway: Gateway, ): - """A reported fan_mode outside fan_modes must return None, not an orphan value.""" + """A reported fan_mode outside the sequence must be appended to fan_modes.""" device_climate_fan = await device_climate_mock(zha_gateway, CLIMATE_FAN) fan_cluster = device_climate_fan.device.endpoints[1].fan entity: ThermostatEntity = get_entity( @@ -1528,8 +1528,13 @@ async def test_fan_mode_out_of_range_returns_none( 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 + assert entity.fan_modes == [ + FanState.LOW, + FanState.MEDIUM, + FanState.HIGH, + FanState.ON, + ] + assert entity.state.fan_mode == FanState.ON async def test_fan_mode_running_state_fallback_clamped( @@ -1559,7 +1564,7 @@ async def test_fan_mode_running_state_fallback_clamped( async def test_fan_mode_off_mapped_correctly( zha_gateway: Gateway, ): - """FanMode.Off must not fall through to 'auto'.""" + """FanMode.Off must map to off (unioned into fan_modes), never 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( @@ -1574,8 +1579,8 @@ async def test_fan_mode_off_mapped_correctly( 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 FanState.OFF in entity.fan_modes + assert entity.state.fan_mode == FanState.OFF assert entity.state.fan_mode != FanState.AUTO diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index 265ac092b..3f5eb89bc 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -638,7 +638,16 @@ def fan_modes(self) -> list[str] | None: if self._fan_cluster is None: return None seq = self._fan_cluster.get(FanCluster.AttributeDefs.fan_mode_sequence.name) - return SEQ_FAN_MODES.get(seq, [FAN_AUTO, FAN_ON]) + modes = list(SEQ_FAN_MODES.get(seq, [FAN_AUTO, FAN_ON])) + # Devices may report a FanMode outside their advertised sequence + # (e.g. On with Low_Med_High_Auto). Union it in so fan_mode stays + # selectable and the fan_mode-in-fan_modes invariant holds. + current = ZCL_TO_FAN_MODE.get( + self._fan_cluster.get(FanCluster.AttributeDefs.fan_mode.name) + ) + if current is not None and current not in modes: + modes.append(current) + return modes @property def hvac_action(self) -> HVACAction | None: From 18dadc3a8fd623dbb7d44ef5ba71f7c7fa517a27 Mon Sep 17 00:00:00 2001 From: vmvarga Date: Tue, 11 Aug 2026 10:00:38 +0200 Subject: [PATCH 11/12] Allow writing FanMode.Off after it is unioned into fan_modes --- tests/test_climate.py | 9 ++++++++- zha/application/platforms/climate/const.py | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index 9421839b0..67a0547ae 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -1564,7 +1564,7 @@ async def test_fan_mode_running_state_fallback_clamped( async def test_fan_mode_off_mapped_correctly( zha_gateway: Gateway, ): - """FanMode.Off must map to off (unioned into fan_modes), never auto.""" + """FanMode.Off must map to off (unioned into fan_modes) and be writable.""" device_climate_fan = await device_climate_mock(zha_gateway, CLIMATE_FAN) fan_cluster = device_climate_fan.device.endpoints[1].fan entity: ThermostatEntity = get_entity( @@ -1583,6 +1583,13 @@ async def test_fan_mode_off_mapped_correctly( assert entity.state.fan_mode == FanState.OFF assert entity.state.fan_mode != FanState.AUTO + fan_cluster.write_attributes.reset_mock() + await entity.async_set_fan_mode(FanState.OFF) + 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": FanMode.Off} + async def test_fan_mode_smart_returns_none( zha_gateway: Gateway, diff --git a/zha/application/platforms/climate/const.py b/zha/application/platforms/climate/const.py index 2ac91297b..c1e5b06bf 100644 --- a/zha/application/platforms/climate/const.py +++ b/zha/application/platforms/climate/const.py @@ -156,6 +156,7 @@ class HVACAction(StrEnum): } FAN_MODE_TO_ZCL: dict[str, FanMode] = { + FAN_OFF: FanMode.Off, FAN_LOW: FanMode.Low, FAN_MEDIUM: FanMode.Medium, FAN_HIGH: FanMode.High, From c1245a71d51bef4031173a29ed8ec12bc9f7b424 Mon Sep 17 00:00:00 2001 From: zigpy-review-bot <286747149+zigpy-review-bot@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:15:24 +0100 Subject: [PATCH 12/12] Simplify fan_mode clamp to a single conditional return --- zha/application/platforms/climate/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index 06542e31c..80cd0dbfc 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -610,9 +610,7 @@ def fan_mode(self) -> str | 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 + return mode if mode in (self.fan_modes or ()) else None running_state = self._running_state if running_state is None: