Replace RuntimeError with custom ServiceValidationError in Tuya (#149175)

This commit is contained in:
epenet 2025-07-23 16:53:36 +02:00 committed by GitHub
parent fad5f7a47b
commit 23b2936174
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 308 additions and 3 deletions

View File

@ -21,6 +21,7 @@ from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
from .entity import TuyaEntity from .entity import TuyaEntity
from .models import IntegerTypeData from .models import IntegerTypeData
from .util import ActionDPCodeNotFoundError
@dataclass(frozen=True) @dataclass(frozen=True)
@ -169,17 +170,28 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
def turn_on(self, **kwargs: Any) -> None: def turn_on(self, **kwargs: Any) -> None:
"""Turn the device on.""" """Turn the device on."""
if self._switch_dpcode is None:
raise ActionDPCodeNotFoundError(
self.device,
self.entity_description.dpcode or self.entity_description.key,
)
self._send_command([{"code": self._switch_dpcode, "value": True}]) self._send_command([{"code": self._switch_dpcode, "value": True}])
def turn_off(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None:
"""Turn the device off.""" """Turn the device off."""
if self._switch_dpcode is None:
raise ActionDPCodeNotFoundError(
self.device,
self.entity_description.dpcode or self.entity_description.key,
)
self._send_command([{"code": self._switch_dpcode, "value": False}]) self._send_command([{"code": self._switch_dpcode, "value": False}])
def set_humidity(self, humidity: int) -> None: def set_humidity(self, humidity: int) -> None:
"""Set new target humidity.""" """Set new target humidity."""
if self._set_humidity is None: if self._set_humidity is None:
raise RuntimeError( raise ActionDPCodeNotFoundError(
"Cannot set humidity, device doesn't provide methods to set it" self.device,
self.entity_description.humidity,
) )
self._send_command( self._send_command(

View File

@ -26,6 +26,7 @@ from .const import (
) )
from .entity import TuyaEntity from .entity import TuyaEntity
from .models import IntegerTypeData from .models import IntegerTypeData
from .util import ActionDPCodeNotFoundError
# All descriptions can be found here. Mostly the Integer data types in the # All descriptions can be found here. Mostly the Integer data types in the
# default instructions set of each category end up being a number. # default instructions set of each category end up being a number.
@ -473,7 +474,7 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
def set_native_value(self, value: float) -> None: def set_native_value(self, value: float) -> None:
"""Set new value.""" """Set new value."""
if self._number is None: if self._number is None:
raise RuntimeError("Cannot set value, device doesn't provide type data") raise ActionDPCodeNotFoundError(self.device, self.entity_description.key)
self._send_command( self._send_command(
[ [

View File

@ -916,5 +916,10 @@
"name": "Siren" "name": "Siren"
} }
} }
},
"exceptions": {
"action_dpcode_not_found": {
"message": "Unable to process action as the device does not provide a corresponding function code (expected one of {expected} in {available})."
}
} }
} }

View File

@ -2,6 +2,12 @@
from __future__ import annotations from __future__ import annotations
from tuya_sharing import CustomerDevice
from homeassistant.exceptions import ServiceValidationError
from .const import DOMAIN, DPCode
def remap_value( def remap_value(
value: float, value: float,
@ -15,3 +21,25 @@ def remap_value(
if reverse: if reverse:
value = from_max - value + from_min value = from_max - value + from_min
return ((value - from_min) / (from_max - from_min)) * (to_max - to_min) + to_min return ((value - from_min) / (from_max - from_min)) * (to_max - to_min) + to_min
class ActionDPCodeNotFoundError(ServiceValidationError):
"""Custom exception for action DP code not found errors."""
def __init__(
self, device: CustomerDevice, expected: str | DPCode | tuple[DPCode, ...] | None
) -> None:
"""Initialize the error with device and expected DP codes."""
if expected is None:
expected = () # empty tuple for no expected codes
elif isinstance(expected, str):
expected = (DPCode(expected),)
super().__init__(
translation_domain=DOMAIN,
translation_key="action_dpcode_not_found",
translation_placeholders={
"expected": str(sorted([dp.value for dp in expected])),
"available": str(sorted(device.function.keys())),
},
)

View File

@ -8,9 +8,16 @@ import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from tuya_sharing import CustomerDevice from tuya_sharing import CustomerDevice
from homeassistant.components.humidifier import (
DOMAIN as HUMIDIFIER_DOMAIN,
SERVICE_SET_HUMIDITY,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
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 ServiceValidationError
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
@ -54,3 +61,180 @@ 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",
["cs_arete_two_12l_dehumidifier_air_purifier"],
)
async def test_turn_on(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test turn on service."""
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": entity_id},
)
await hass.async_block_till_done()
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "switch", "value": True}]
)
@pytest.mark.parametrize(
"mock_device_code",
["cs_arete_two_12l_dehumidifier_air_purifier"],
)
async def test_turn_off(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test turn off service."""
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_TURN_OFF,
{"entity_id": entity_id},
)
await hass.async_block_till_done()
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "switch", "value": False}]
)
@pytest.mark.parametrize(
"mock_device_code",
["cs_arete_two_12l_dehumidifier_air_purifier"],
)
async def test_set_humidity(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test set humidity service."""
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_SET_HUMIDITY,
{
"entity_id": entity_id,
"humidity": 50,
},
)
await hass.async_block_till_done()
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "dehumidify_set_value", "value": 50}]
)
@pytest.mark.parametrize(
"mock_device_code",
["cs_smart_dry_plus"],
)
async def test_turn_on_unsupported(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test turn on service (not supported by this device)."""
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": entity_id},
blocking=True,
)
assert err.value.translation_key == "action_dpcode_not_found"
assert err.value.translation_placeholders == {
"expected": "['switch', 'switch_spray']",
"available": ("[]"),
}
@pytest.mark.parametrize(
"mock_device_code",
["cs_smart_dry_plus"],
)
async def test_turn_off_unsupported(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test turn off service (not supported by this device)."""
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_TURN_OFF,
{"entity_id": entity_id},
blocking=True,
)
assert err.value.translation_key == "action_dpcode_not_found"
assert err.value.translation_placeholders == {
"expected": "['switch', 'switch_spray']",
"available": ("[]"),
}
@pytest.mark.parametrize(
"mock_device_code",
["cs_smart_dry_plus"],
)
async def test_set_humidity_unsupported(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test set humidity service (not supported by this device)."""
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_SET_HUMIDITY,
{
"entity_id": entity_id,
"humidity": 50,
},
blocking=True,
)
assert err.value.translation_key == "action_dpcode_not_found"
assert err.value.translation_placeholders == {
"expected": "['dehumidify_set_value']",
"available": ("[]"),
}

View File

@ -8,9 +8,11 @@ import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from tuya_sharing import CustomerDevice from tuya_sharing import CustomerDevice
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE
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 ServiceValidationError
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
@ -53,3 +55,76 @@ 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",
["mal_alarm_host"],
)
async def test_set_value(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test set value."""
entity_id = "number.multifunction_alarm_arm_delay"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
"entity_id": entity_id,
"value": 18,
},
)
await hass.async_block_till_done()
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "delay_set", "value": 18}]
)
@pytest.mark.parametrize(
"mock_device_code",
["mal_alarm_host"],
)
async def test_set_value_no_function(
hass: HomeAssistant,
mock_manager: ManagerCompat,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test set value when no function available."""
# Mock a device with delay_set in status but not in function or status_range
mock_device.function.pop("delay_set")
mock_device.status_range.pop("delay_set")
entity_id = "number.multifunction_alarm_arm_delay"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
"entity_id": entity_id,
"value": 18,
},
blocking=True,
)
assert err.value.translation_key == "action_dpcode_not_found"
assert err.value.translation_placeholders == {
"expected": "['delay_set']",
"available": (
"['alarm_delay_time', 'alarm_time', 'master_mode', 'master_state', "
"'muffling', 'sub_admin', 'sub_class', 'switch_alarm_light', "
"'switch_alarm_propel', 'switch_alarm_sound', 'switch_kb_light', "
"'switch_kb_sound', 'switch_mode_sound']"
),
}