Skip to content
8 changes: 5 additions & 3 deletions tests/data/devices/atlantic-group-adapter-zigbee-fujitsu.json
Original file line number Diff line number Diff line change
Expand Up @@ -389,8 +389,10 @@
"min_temp": 16.0,
"supported_features": 395,
"fan_modes": [
"auto",
"on"
"low",
"medium",
"high",
"auto"
],
"preset_modes": [],
"hvac_modes": [
Expand All @@ -411,7 +413,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,
Expand Down
4 changes: 2 additions & 2 deletions tests/data/devices/centralite-systems-3156105.json
Original file line number Diff line number Diff line change
Expand Up @@ -450,8 +450,8 @@
"min_temp": 7.0,
"supported_features": 393,
"fan_modes": [
"auto",
"on"
"on",
"auto"
],
"preset_modes": [],
"hvac_modes": [
Expand Down
6 changes: 4 additions & 2 deletions tests/data/devices/enktro-acmidea.json
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,10 @@
"min_temp": 17.0,
"supported_features": 395,
"fan_modes": [
"auto",
"on"
"low",
"medium",
"high",
"auto"
],
"preset_modes": [],
"hvac_modes": [
Expand Down
4 changes: 2 additions & 2 deletions tests/data/devices/zen-within-zen-01.json
Original file line number Diff line number Diff line change
Expand Up @@ -415,8 +415,8 @@
"min_temp": 4.0,
"supported_features": 395,
"fan_modes": [
"auto",
"on"
"on",
"auto"
],
"preset_modes": [],
"hvac_modes": [
Expand Down
61 changes: 59 additions & 2 deletions tests/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import asyncio
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1298,6 +1302,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,
):
Expand Down
30 changes: 25 additions & 5 deletions zha/application/platforms/climate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,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,
Expand Down Expand Up @@ -595,6 +599,11 @@ 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:
return ZCL_TO_FAN_MODE.get(current, FAN_AUTO)

running_state = self._running_state
if running_state is None:
return FAN_AUTO
Expand All @@ -607,12 +616,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_ON, FAN_AUTO])

@property
def hvac_action(self) -> HVACAction | None:
Expand Down Expand Up @@ -670,7 +680,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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not saying we should, but if we wanted to, we could also have another entity class that overrides the fist one (so likely using a feature group) and then matches on the exposed feature. There, we'd only add FAN_ONLY to the list of possible modes. It would keep the base ZCL class clean of the exposed feature, but I think either is fine.

and HVACMode.FAN_ONLY not in modes
):
modes = [*modes, HVACMode.FAN_ONLY]
return modes

@property
def preset_mode(self) -> str:
Expand Down Expand Up @@ -801,10 +818,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:
Expand Down
32 changes: 31 additions & 1 deletion zha/application/platforms/climate/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Comment on lines +31 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should really live in zha-quirks at the moment: https://github.com/zigpy/zha-device-handlers/blob/dev/zhaquirks/quirk_ids.py


PRECISION_TENTHS: Final[float] = 0.1

# Possible fan state
Expand Down Expand Up @@ -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,
Expand Down
Loading