Skip to content

Fix Tuya support for climate fan modes which use "windspeed" function #148646

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
13 changes: 11 additions & 2 deletions homeassistant/components/tuya/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

Expand Down Expand Up @@ -250,13 +251,15 @@ def __init__(
)

# Determine fan modes
self._fan_dp_code = None
if enum_type := self.find_dpcode(
(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED),
dptype=DPType.ENUM,
prefer_function=True,
):
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
self._attr_fan_modes = enum_type.range
self._fan_dp_code = enum_type.dpcode

# Determine swing modes
if self.find_dpcode(
Expand Down Expand Up @@ -304,7 +307,10 @@ def set_preset_mode(self, preset_mode: str) -> None:

def set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
self._send_command([{"code": DPCode.FAN_SPEED_ENUM, "value": fan_mode}])
if not self._fan_dp_code:
raise HomeAssistantError("No valid fan DPCode set for this device.")
Copy link
Preview

Copilot AI Jul 12, 2025

Choose a reason for hiding this comment

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

[nitpick] set_fan_mode raises HomeAssistantError while other setter methods use RuntimeError; consider unifying the exception type for consistency.

Suggested change
raise HomeAssistantError("No valid fan DPCode set for this device.")
raise RuntimeError("No valid fan DPCode set for this device.")

Copilot uses AI. Check for mistakes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

50/50 on this one

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it's better to keep aligned with other platforms for now.
There is a probably a case for migrating all to HomeAssistantError, but I think having them all aligned is best for now

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's actually not possible to get to this state as self._fan_dp_code sets fan_mode to None so a ServiceNotSupported exception is raised before this can occur. Would it be safe to remove this check?


self._send_command([{"code": self._fan_dp_code, "value": fan_mode}])

def set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
Expand Down Expand Up @@ -460,7 +466,10 @@ def preset_mode(self) -> str | None:
@property
def fan_mode(self) -> str | None:
"""Return fan mode."""
return self.device.status.get(DPCode.FAN_SPEED_ENUM)
if not self._fan_dp_code:
return None

return self.device.status.get(self._fan_dp_code)

@property
def swing_mode(self) -> str:
Expand Down
4 changes: 4 additions & 0 deletions tests/components/tuya/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@
Platform.SELECT,
Platform.SWITCH,
],
"kt_serenelife_slpac905wuk_air_conditioner": [
Copy link
Contributor

Choose a reason for hiding this comment

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

Please keep in alphabetical order (after ks_tower_fan)

# https://github.com/home-assistant/core/pull/148646
Platform.CLIMATE,
],
"ks_tower_fan": [
# https://github.com/orgs/home-assistant/discussions/329
Platform.FAN,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"endpoint": "https://apigw.tuyaeu.com",
"terminal_id": "mock_terminal_id",
"mqtt_connected": true,
"disabled_by": null,
"disabled_polling": false,
"id": "mock_device_id",
"name": "Air Conditioner",
"category": "kt",
"product_id": "5wnlzekkstwcdsvm",
"product_name": "\u79fb\u52a8\u7a7a\u8c03 YPK--\uff08\u53cc\u6a21+\u84dd\u7259\uff09\u4f4e\u529f\u8017",
"online": true,
"sub": false,
"time_zone": "+01:00",
"active_time": "2025-07-06T10:10:44+00:00",
"create_time": "2025-07-06T10:10:44+00:00",
"update_time": "2025-07-06T10:10:44+00:00",
"function": {
"switch": {
"type": "Boolean",
"value": {}
},
"temp_set": {
"type": "Integer",
"value": {
"unit": "\u2103 \u2109",
"min": 16,
"max": 86,
"scale": 0,
"step": 1
}
},
"windspeed": {
"type": "Enum",
"value": {
"range": ["1", "2"]
}
}
},
"status_range": {
"switch": {
"type": "Boolean",
"value": {}
},
"temp_set": {
"type": "Integer",
"value": {
"unit": "\u2103 \u2109",
"min": 16,
"max": 86,
"scale": 0,
"step": 1
}
},
"temp_current": {
"type": "Integer",
"value": {
"unit": "\u2103 \u2109",
"min": -7,
"max": 98,
"scale": 0,
"step": 1
}
},
"windspeed": {
"type": "Enum",
"value": {
"range": ["1", "2"]
}
}
},
"status": {
"switch": false,
"temp_set": 23,
"temp_current": 22,
"windspeed": 1
},
"set_up": true,
"support_local": true
}
75 changes: 75 additions & 0 deletions tests/components/tuya/snapshots/test_climate.ambr
Original file line number Diff line number Diff line change
@@ -1,4 +1,79 @@
# serializer version: 1
# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'fan_modes': list([
'1',
'2',
]),
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.COOL: 'cool'>,
]),
'max_temp': 86.0,
'min_temp': 16.0,
'target_temp_step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.air_conditioner',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 393>,
'translation_key': None,
'unique_id': 'tuya.mock_device_id',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 22.0,
'fan_mode': 1,
'fan_modes': list([
'1',
'2',
]),
'friendly_name': 'Air Conditioner',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.COOL: 'cool'>,
]),
'max_temp': 86.0,
'min_temp': 16.0,
'supported_features': <ClimateEntityFeature: 393>,
'target_temp_step': 1.0,
'temperature': 23.0,
}),
'context': <ANY>,
'entity_id': 'climate.air_conditioner',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
Expand Down
81 changes: 79 additions & 2 deletions tests/components/tuya/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,99 @@

from __future__ import annotations

from typing import cast
from unittest.mock import patch

import pytest
from syrupy.assertion import SnapshotAssertion
from tuya_sharing import CustomerDevice
from tuya_sharing import CustomerDevice, Manager

from homeassistant.components.climate import HVACMode
from homeassistant.components.tuya import ManagerCompat
from homeassistant.const import Platform
from homeassistant.components.tuya.climate import (
TuyaClimateEntity,
TuyaClimateEntityDescription,
)
from homeassistant.components.tuya.const import DPCode
from homeassistant.const import Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er

from . import DEVICE_MOCKS, initialize_entry

from tests.common import MockConfigEntry, snapshot_platform


class DummyDevice:
"""Dummy device for testing."""

def __init__(self, function, status) -> None:
"""Initialize the dummy device."""
self.function = function
self.status = status
self.id = "dummy"
self.name = "Dummy"
self.product_name = "Dummy"
self.product_id = "dummy"
self.status_range = {}
self.online = True


class DummyManager:
"""Dummy manager for testing."""

def send_commands(self, device_id: str, commands: list) -> None:
"""Send commands to the device."""


class DummyFunction:
"""Dummy function for testing."""

def __init__(self, type_: str, values: str) -> None:
"""Initialize the dummy function."""
self.type = type_
self.values = values


def make_climate_entity(function, status):
"""Make a dummy climate entity for testing."""
return TuyaClimateEntity(
cast("CustomerDevice", DummyDevice(function, status)),
cast("Manager", DummyManager()),
TuyaClimateEntityDescription(key="kt", switch_only_hvac_mode=HVACMode.COOL),
UnitOfTemperature.CELSIUS,
)


def test_fan_mode_windspeed() -> None:
Copy link
Contributor

@epenet epenet Jul 16, 2025

Choose a reason for hiding this comment

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

Please move the tests to the bottom of the file, after the setup tests.

Also, please look at test_cover.py for running tests on a mocked device.
You should not be creating a TuyaClimateEntity manually, and you should be able to drop make_climate_entity / DummyFunction / DummyManager / DummyDevice

@pytest.mark.parametrize(
"mock_device_code",
["am43_corded_motor_zigbee_cover"],
)

"""Test fan mode with windspeed."""
entity = make_climate_entity(
{"windspeed": DummyFunction("Enum", '{"range": ["1", "2"]}')},
{"windspeed": "2"},
)
assert entity.fan_mode == "2"
entity.set_fan_mode("1")
Copy link
Contributor

@epenet epenet Jul 16, 2025

Choose a reason for hiding this comment

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

set_fan_mode should be tested using service calls - not direct function calls on the entity
The best would be to make the service call, and then check that the manager received the correct instructions



def test_fan_mode_fan_speed_enum() -> None:
"""Test fan mode with fan speed enum."""
entity = make_climate_entity(
{DPCode.FAN_SPEED_ENUM: DummyFunction("Enum", '{"range": ["1", "2"]}')},
{DPCode.FAN_SPEED_ENUM: "1"},
)
assert entity.fan_mode == "1"
entity.set_fan_mode("2")


def test_fan_mode_no_valid_code() -> None:
"""Test fan mode with no valid code."""
entity = make_climate_entity({}, {})
assert entity.fan_mode is None
with pytest.raises(HomeAssistantError):
entity.set_fan_mode("1")


@pytest.mark.parametrize(
"mock_device_code",
[k for k, v in DEVICE_MOCKS.items() if Platform.CLIMATE in v],
Expand Down
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy