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

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
Aidan Timson 2025-07-17 08:47:54 +01:00 committed by GitHub
parent 3d278b626a
commit 72d1c3cfc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 236 additions and 3 deletions

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import TYPE_CHECKING, Any
from tuya_sharing import CustomerDevice, Manager from tuya_sharing import CustomerDevice, Manager
@ -250,6 +250,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
) )
# Determine fan modes # Determine fan modes
self._fan_mode_dp_code: str | None = None
if enum_type := self.find_dpcode( if enum_type := self.find_dpcode(
(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), (DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED),
dptype=DPType.ENUM, dptype=DPType.ENUM,
@ -257,6 +258,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
): ):
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
self._attr_fan_modes = enum_type.range self._attr_fan_modes = enum_type.range
self._fan_mode_dp_code = enum_type.dpcode
# Determine swing modes # Determine swing modes
if self.find_dpcode( if self.find_dpcode(
@ -304,7 +306,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
def set_fan_mode(self, fan_mode: str) -> None: def set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode.""" """Set new target fan mode."""
self._send_command([{"code": DPCode.FAN_SPEED_ENUM, "value": fan_mode}]) if TYPE_CHECKING:
# We can rely on supported_features from __init__
assert self._fan_mode_dp_code is not None
self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}])
def set_humidity(self, humidity: int) -> None: def set_humidity(self, humidity: int) -> None:
"""Set new target humidity.""" """Set new target humidity."""
@ -460,7 +466,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
@property @property
def fan_mode(self) -> str | None: def fan_mode(self) -> str | None:
"""Return fan mode.""" """Return fan mode."""
return self.device.status.get(DPCode.FAN_SPEED_ENUM) return (
self.device.status.get(self._fan_mode_dp_code)
if self._fan_mode_dp_code
else None
)
@property @property
def swing_mode(self) -> str: def swing_mode(self) -> str:

View File

@ -107,6 +107,10 @@ DEVICE_MOCKS = {
Platform.LIGHT, Platform.LIGHT,
Platform.SWITCH, Platform.SWITCH,
], ],
"kt_serenelife_slpac905wuk_air_conditioner": [
# https://github.com/home-assistant/core/pull/148646
Platform.CLIMATE,
],
"mal_alarm_host": [ "mal_alarm_host": [
# Alarm Host support # Alarm Host support
Platform.ALARM_CONTROL_PANEL, Platform.ALARM_CONTROL_PANEL,

View File

@ -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
}

View File

@ -1,4 +1,79 @@
# serializer version: 1 # 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] # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

View File

@ -11,6 +11,7 @@ from tuya_sharing import CustomerDevice
from homeassistant.components.tuya import ManagerCompat from homeassistant.components.tuya import ManagerCompat
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceNotSupported
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from . import DEVICE_MOCKS, initialize_entry from . import DEVICE_MOCKS, initialize_entry
@ -55,3 +56,66 @@ async def test_platform_setup_no_discovery(
assert not er.async_entries_for_config_entry( assert not er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id entity_registry, mock_config_entry.entry_id
) )
@pytest.mark.parametrize(
"mock_device_code",
["kt_serenelife_slpac905wuk_air_conditioner"],
)
async def test_fan_mode_windspeed(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test fan mode with windspeed."""
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get("climate.air_conditioner")
assert state is not None, "climate.air_conditioner does not exist"
assert state.attributes["fan_mode"] == 1
await hass.services.async_call(
Platform.CLIMATE,
"set_fan_mode",
{
"entity_id": "climate.air_conditioner",
"fan_mode": 2,
},
)
await hass.async_block_till_done()
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "windspeed", "value": "2"}]
)
@pytest.mark.parametrize(
"mock_device_code",
["kt_serenelife_slpac905wuk_air_conditioner"],
)
async def test_fan_mode_no_valid_code(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test fan mode with no valid code."""
# Remove windspeed DPCode to simulate a device with no valid fan mode
mock_device.function.pop("windspeed", None)
mock_device.status_range.pop("windspeed", None)
mock_device.status.pop("windspeed", None)
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get("climate.air_conditioner")
assert state is not None, "climate.air_conditioner does not exist"
assert state.attributes.get("fan_mode") is None
with pytest.raises(ServiceNotSupported):
await hass.services.async_call(
Platform.CLIMATE,
"set_fan_mode",
{
"entity_id": "climate.air_conditioner",
"fan_mode": 2,
},
blocking=True,
)