Skip to content

Commit 72d1c3c

Browse files
timmo001Copilotepenet
authored
Fix Tuya support for climate fan modes which use "windspeed" function (home-assistant#148646)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
1 parent 3d278b6 commit 72d1c3c

5 files changed

Lines changed: 236 additions & 3 deletions

File tree

homeassistant/components/tuya/climate.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from dataclasses import dataclass
6-
from typing import Any
6+
from typing import TYPE_CHECKING, Any
77

88
from tuya_sharing import CustomerDevice, Manager
99

@@ -250,13 +250,15 @@ def __init__(
250250
)
251251

252252
# Determine fan modes
253+
self._fan_mode_dp_code: str | None = None
253254
if enum_type := self.find_dpcode(
254255
(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED),
255256
dptype=DPType.ENUM,
256257
prefer_function=True,
257258
):
258259
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
259260
self._attr_fan_modes = enum_type.range
261+
self._fan_mode_dp_code = enum_type.dpcode
260262

261263
# Determine swing modes
262264
if self.find_dpcode(
@@ -304,7 +306,11 @@ def set_preset_mode(self, preset_mode: str) -> None:
304306

305307
def set_fan_mode(self, fan_mode: str) -> None:
306308
"""Set new target fan mode."""
307-
self._send_command([{"code": DPCode.FAN_SPEED_ENUM, "value": fan_mode}])
309+
if TYPE_CHECKING:
310+
# We can rely on supported_features from __init__
311+
assert self._fan_mode_dp_code is not None
312+
313+
self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}])
308314

309315
def set_humidity(self, humidity: int) -> None:
310316
"""Set new target humidity."""
@@ -460,7 +466,11 @@ def preset_mode(self) -> str | None:
460466
@property
461467
def fan_mode(self) -> str | None:
462468
"""Return fan mode."""
463-
return self.device.status.get(DPCode.FAN_SPEED_ENUM)
469+
return (
470+
self.device.status.get(self._fan_mode_dp_code)
471+
if self._fan_mode_dp_code
472+
else None
473+
)
464474

465475
@property
466476
def swing_mode(self) -> str:

tests/components/tuya/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@
107107
Platform.LIGHT,
108108
Platform.SWITCH,
109109
],
110+
"kt_serenelife_slpac905wuk_air_conditioner": [
111+
# https://github.com/home-assistant/core/pull/148646
112+
Platform.CLIMATE,
113+
],
110114
"mal_alarm_host": [
111115
# Alarm Host support
112116
Platform.ALARM_CONTROL_PANEL,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
{
2+
"endpoint": "https://apigw.tuyaeu.com",
3+
"terminal_id": "mock_terminal_id",
4+
"mqtt_connected": true,
5+
"disabled_by": null,
6+
"disabled_polling": false,
7+
"id": "mock_device_id",
8+
"name": "Air Conditioner",
9+
"category": "kt",
10+
"product_id": "5wnlzekkstwcdsvm",
11+
"product_name": "\u79fb\u52a8\u7a7a\u8c03 YPK--\uff08\u53cc\u6a21+\u84dd\u7259\uff09\u4f4e\u529f\u8017",
12+
"online": true,
13+
"sub": false,
14+
"time_zone": "+01:00",
15+
"active_time": "2025-07-06T10:10:44+00:00",
16+
"create_time": "2025-07-06T10:10:44+00:00",
17+
"update_time": "2025-07-06T10:10:44+00:00",
18+
"function": {
19+
"switch": {
20+
"type": "Boolean",
21+
"value": {}
22+
},
23+
"temp_set": {
24+
"type": "Integer",
25+
"value": {
26+
"unit": "\u2103 \u2109",
27+
"min": 16,
28+
"max": 86,
29+
"scale": 0,
30+
"step": 1
31+
}
32+
},
33+
"windspeed": {
34+
"type": "Enum",
35+
"value": {
36+
"range": ["1", "2"]
37+
}
38+
}
39+
},
40+
"status_range": {
41+
"switch": {
42+
"type": "Boolean",
43+
"value": {}
44+
},
45+
"temp_set": {
46+
"type": "Integer",
47+
"value": {
48+
"unit": "\u2103 \u2109",
49+
"min": 16,
50+
"max": 86,
51+
"scale": 0,
52+
"step": 1
53+
}
54+
},
55+
"temp_current": {
56+
"type": "Integer",
57+
"value": {
58+
"unit": "\u2103 \u2109",
59+
"min": -7,
60+
"max": 98,
61+
"scale": 0,
62+
"step": 1
63+
}
64+
},
65+
"windspeed": {
66+
"type": "Enum",
67+
"value": {
68+
"range": ["1", "2"]
69+
}
70+
}
71+
},
72+
"status": {
73+
"switch": false,
74+
"temp_set": 23,
75+
"temp_current": 22,
76+
"windspeed": 1
77+
},
78+
"set_up": true,
79+
"support_local": true
80+
}

tests/components/tuya/snapshots/test_climate.ambr

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,79 @@
11
# serializer version: 1
2+
# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-entry]
3+
EntityRegistryEntrySnapshot({
4+
'aliases': set({
5+
}),
6+
'area_id': None,
7+
'capabilities': dict({
8+
'fan_modes': list([
9+
'1',
10+
'2',
11+
]),
12+
'hvac_modes': list([
13+
<HVACMode.OFF: 'off'>,
14+
<HVACMode.COOL: 'cool'>,
15+
]),
16+
'max_temp': 86.0,
17+
'min_temp': 16.0,
18+
'target_temp_step': 1.0,
19+
}),
20+
'config_entry_id': <ANY>,
21+
'config_subentry_id': <ANY>,
22+
'device_class': None,
23+
'device_id': <ANY>,
24+
'disabled_by': None,
25+
'domain': 'climate',
26+
'entity_category': None,
27+
'entity_id': 'climate.air_conditioner',
28+
'has_entity_name': True,
29+
'hidden_by': None,
30+
'icon': None,
31+
'id': <ANY>,
32+
'labels': set({
33+
}),
34+
'name': None,
35+
'options': dict({
36+
}),
37+
'original_device_class': None,
38+
'original_icon': None,
39+
'original_name': None,
40+
'platform': 'tuya',
41+
'previous_unique_id': None,
42+
'suggested_object_id': None,
43+
'supported_features': <ClimateEntityFeature: 393>,
44+
'translation_key': None,
45+
'unique_id': 'tuya.mock_device_id',
46+
'unit_of_measurement': None,
47+
})
48+
# ---
49+
# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-state]
50+
StateSnapshot({
51+
'attributes': ReadOnlyDict({
52+
'current_temperature': 22.0,
53+
'fan_mode': 1,
54+
'fan_modes': list([
55+
'1',
56+
'2',
57+
]),
58+
'friendly_name': 'Air Conditioner',
59+
'hvac_modes': list([
60+
<HVACMode.OFF: 'off'>,
61+
<HVACMode.COOL: 'cool'>,
62+
]),
63+
'max_temp': 86.0,
64+
'min_temp': 16.0,
65+
'supported_features': <ClimateEntityFeature: 393>,
66+
'target_temp_step': 1.0,
67+
'temperature': 23.0,
68+
}),
69+
'context': <ANY>,
70+
'entity_id': 'climate.air_conditioner',
71+
'last_changed': <ANY>,
72+
'last_reported': <ANY>,
73+
'last_updated': <ANY>,
74+
'state': 'off',
75+
})
76+
# ---
277
# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry]
378
EntityRegistryEntrySnapshot({
479
'aliases': set({

tests/components/tuya/test_climate.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from homeassistant.components.tuya import ManagerCompat
1212
from homeassistant.const import Platform
1313
from homeassistant.core import HomeAssistant
14+
from homeassistant.exceptions import ServiceNotSupported
1415
from homeassistant.helpers import entity_registry as er
1516

1617
from . import DEVICE_MOCKS, initialize_entry
@@ -55,3 +56,66 @@ async def test_platform_setup_no_discovery(
5556
assert not er.async_entries_for_config_entry(
5657
entity_registry, mock_config_entry.entry_id
5758
)
59+
60+
61+
@pytest.mark.parametrize(
62+
"mock_device_code",
63+
["kt_serenelife_slpac905wuk_air_conditioner"],
64+
)
65+
async def test_fan_mode_windspeed(
66+
hass: HomeAssistant,
67+
mock_manager: ManagerCompat,
68+
mock_config_entry: MockConfigEntry,
69+
mock_device: CustomerDevice,
70+
) -> None:
71+
"""Test fan mode with windspeed."""
72+
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
73+
74+
state = hass.states.get("climate.air_conditioner")
75+
assert state is not None, "climate.air_conditioner does not exist"
76+
assert state.attributes["fan_mode"] == 1
77+
await hass.services.async_call(
78+
Platform.CLIMATE,
79+
"set_fan_mode",
80+
{
81+
"entity_id": "climate.air_conditioner",
82+
"fan_mode": 2,
83+
},
84+
)
85+
await hass.async_block_till_done()
86+
mock_manager.send_commands.assert_called_once_with(
87+
mock_device.id, [{"code": "windspeed", "value": "2"}]
88+
)
89+
90+
91+
@pytest.mark.parametrize(
92+
"mock_device_code",
93+
["kt_serenelife_slpac905wuk_air_conditioner"],
94+
)
95+
async def test_fan_mode_no_valid_code(
96+
hass: HomeAssistant,
97+
mock_manager: ManagerCompat,
98+
mock_config_entry: MockConfigEntry,
99+
mock_device: CustomerDevice,
100+
) -> None:
101+
"""Test fan mode with no valid code."""
102+
# Remove windspeed DPCode to simulate a device with no valid fan mode
103+
mock_device.function.pop("windspeed", None)
104+
mock_device.status_range.pop("windspeed", None)
105+
mock_device.status.pop("windspeed", None)
106+
107+
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
108+
109+
state = hass.states.get("climate.air_conditioner")
110+
assert state is not None, "climate.air_conditioner does not exist"
111+
assert state.attributes.get("fan_mode") is None
112+
with pytest.raises(ServiceNotSupported):
113+
await hass.services.async_call(
114+
Platform.CLIMATE,
115+
"set_fan_mode",
116+
{
117+
"entity_id": "climate.air_conditioner",
118+
"fan_mode": 2,
119+
},
120+
blocking=True,
121+
)

0 commit comments

Comments
 (0)