Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d25a53f
Fix thermostat fan modes to respect fanModeSequence and expose FAN_ON…
vmvarga Mar 12, 2026
2886d25
Apply pre-commit auto fixes
pre-commit-ci[bot] Mar 12, 2026
2304728
Update constants
vmvarga Mar 12, 2026
75510aa
Merge branch 'dev' of https://github.com/vmvarga/zha into dev
vmvarga Mar 12, 2026
dad0f4d
Merge branch 'dev' of https://github.com/zigpy/zha into dev
vmvarga Mar 13, 2026
edfb246
add test
vmvarga Mar 13, 2026
cb6aa3b
Merge branch 'dev' of https://github.com/zigpy/zha into dev
vmvarga Apr 20, 2026
4e3737c
Opt-in fan only via quirks
vmvarga Apr 20, 2026
4f3b43b
Merge remote-tracking branch 'origin/dev' into dev
vmvarga Jun 11, 2026
08c3a7a
Merge branch 'dev' into dev
vmvarga Jun 12, 2026
2dcb767
move fan_only_hvac to zhaquirks
vmvarga Jun 15, 2026
29b4b0d
Merge branch 'dev' of https://github.com/zigpy/zha into dev
vmvarga Jun 15, 2026
4b4a111
Merge branch 'dev' of https://github.com/vmvarga/zha into dev
vmvarga Jun 15, 2026
dcee206
Merge remote-tracking branch 'origin/dev' into dev
vmvarga Jun 25, 2026
a83fa04
Merge remote-tracking branch 'origin/dev' into dev
vmvarga Jul 8, 2026
b3752d5
Merge branch 'dev' into dev
vmvarga Jul 11, 2026
f4ca46c
Merge branch 'dev' into dev
vmvarga Jul 16, 2026
02195a6
Merge branch 'dev' into dev
vmvarga Jul 17, 2026
e636bb3
Merge branch 'dev' into vmvarga-dev
puddly Jul 29, 2026
cb51c85
pre-commit
puddly Jul 29, 2026
4db4310
Fix thermostat fan mode event wiring and ZCL mapping edge cases
vmvarga Jul 29, 2026
666c011
Merge branch 'dev' into dev
vmvarga Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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": [
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 @@ -325,8 +325,10 @@
"min_temp": 17.0,
"supported_features": 395,
"fan_modes": [
"auto",
"on"
"low",
"medium",
"high",
"auto"
],
"preset_modes": [],
"hvac_modes": [
Expand Down
259 changes: 255 additions & 4 deletions tests/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -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__)
Expand Down Expand Up @@ -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(
Comment thread
vmvarga marked this conversation as resolved.
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 All @@ -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."""

Expand Down
Loading