Refactor Tuya DPCode and data type handling (#64707)

This commit is contained in:
Franck Nijhof 2022-01-23 09:01:10 +01:00 committed by GitHub
parent a5fb60fd3a
commit db979fef6c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 429 additions and 500 deletions

View file

@ -23,8 +23,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeAssistantTuyaData from . import HomeAssistantTuyaData
from .base import EnumTypeData, TuyaEntity from .base import TuyaEntity
from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType
class Mode(StrEnum): class Mode(StrEnum):
@ -105,36 +105,39 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
self._attr_unique_id = f"{super().unique_id}{description.key}" self._attr_unique_id = f"{super().unique_id}{description.key}"
# Determine supported modes # Determine supported modes
supported_mode = EnumTypeData.from_json( if supported_modes := self.find_dpcode(
device.function[DPCode.MASTER_MODE].values description.key, dptype=DPType.ENUM, prefer_function=True
).range ):
if Mode.HOME in supported_modes.range:
self._attr_supported_features |= SUPPORT_ALARM_ARM_HOME
if Mode.HOME in supported_mode: if Mode.ARM in supported_modes.range:
self._attr_supported_features |= SUPPORT_ALARM_ARM_HOME self._attr_supported_features |= SUPPORT_ALARM_ARM_AWAY
if Mode.ARM in supported_mode: if Mode.SOS in supported_modes.range:
self._attr_supported_features |= SUPPORT_ALARM_ARM_AWAY self._attr_supported_features |= SUPPORT_ALARM_TRIGGER
if Mode.SOS in supported_mode:
self._attr_supported_features |= SUPPORT_ALARM_TRIGGER
@property @property
def state(self): def state(self):
"""Return the state of the device.""" """Return the state of the device."""
return STATE_MAPPING.get(self.device.status.get(DPCode.MASTER_MODE)) if not (status := self.device.status.get(self.entity_description.key)):
return None
return STATE_MAPPING.get(status)
def alarm_disarm(self, code: str | None = None) -> None: def alarm_disarm(self, code: str | None = None) -> None:
"""Send Disarm command.""" """Send Disarm command."""
self._send_command([{"code": DPCode.MASTER_MODE, "value": Mode.DISARMED}]) self._send_command(
[{"code": self.entity_description.key, "value": Mode.DISARMED}]
)
def alarm_arm_home(self, code: str | None = None) -> None: def alarm_arm_home(self, code: str | None = None) -> None:
"""Send Home command.""" """Send Home command."""
self._send_command([{"code": DPCode.MASTER_MODE, "value": Mode.HOME}]) self._send_command([{"code": self.entity_description.key, "value": Mode.HOME}])
def alarm_arm_away(self, code: str | None = None) -> None: def alarm_arm_away(self, code: str | None = None) -> None:
"""Send Arm command.""" """Send Arm command."""
self._send_command([{"code": DPCode.MASTER_MODE, "value": Mode.ARM}]) self._send_command([{"code": self.entity_description.key, "value": Mode.ARM}])
def alarm_trigger(self, code: str | None = None) -> None: def alarm_trigger(self, code: str | None = None) -> None:
"""Send SOS command.""" """Send SOS command."""
self._send_command([{"code": DPCode.MASTER_MODE, "value": Mode.SOS}]) self._send_command([{"code": self.entity_description.key, "value": Mode.SOS}])

View file

@ -5,14 +5,14 @@ import base64
from dataclasses import dataclass from dataclasses import dataclass
import json import json
import struct import struct
from typing import Any from typing import Any, Literal, overload
from tuya_iot import TuyaDevice, TuyaDeviceManager from tuya_iot import TuyaDevice, TuyaDeviceManager
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType
from .util import remap_value from .util import remap_value
@ -20,6 +20,7 @@ from .util import remap_value
class IntegerTypeData: class IntegerTypeData:
"""Integer Type Data.""" """Integer Type Data."""
dpcode: DPCode
min: int min: int
max: int max: int
scale: float scale: float
@ -71,21 +72,22 @@ class IntegerTypeData:
return remap_value(value, from_min, from_max, self.min, self.max, reverse) return remap_value(value, from_min, from_max, self.min, self.max, reverse)
@classmethod @classmethod
def from_json(cls, data: str) -> IntegerTypeData: def from_json(cls, dpcode: DPCode, data: str) -> IntegerTypeData:
"""Load JSON string and return a IntegerTypeData object.""" """Load JSON string and return a IntegerTypeData object."""
return cls(**json.loads(data)) return cls(dpcode, **json.loads(data))
@dataclass @dataclass
class EnumTypeData: class EnumTypeData:
"""Enum Type Data.""" """Enum Type Data."""
dpcode: DPCode
range: list[str] range: list[str]
@classmethod @classmethod
def from_json(cls, data: str) -> EnumTypeData: def from_json(cls, dpcode: DPCode, data: str) -> EnumTypeData:
"""Load JSON string and return a EnumTypeData object.""" """Load JSON string and return a EnumTypeData object."""
return cls(**json.loads(data)) return cls(dpcode, **json.loads(data))
@dataclass @dataclass
@ -149,6 +151,101 @@ class TuyaEntity(Entity):
"""Return if the device is available.""" """Return if the device is available."""
return self.device.online return self.device.online
@overload
def find_dpcode(
self,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.ENUM],
) -> EnumTypeData | None:
...
@overload
def find_dpcode(
self,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.INTEGER],
) -> IntegerTypeData | None:
...
@overload
def find_dpcode(
self,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
) -> DPCode | None:
...
def find_dpcode(
self,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: DPType = None,
) -> DPCode | EnumTypeData | IntegerTypeData | None:
"""Find a matching DP code available on for this device."""
if dpcodes is None:
return None
if isinstance(dpcodes, str):
dpcodes = (DPCode(dpcodes),)
elif not isinstance(dpcodes, tuple):
dpcodes = (dpcodes,)
order = ["status_range", "function"]
if prefer_function:
order = ["function", "status_range"]
# When we are not looking for a specific datatype, we can append status for
# searching
if not dptype:
order.append("status")
for dpcode in dpcodes:
for key in order:
if dpcode not in getattr(self.device, key):
continue
if (
dptype == DPType.ENUM
and getattr(self.device, key)[dpcode].type == DPType.ENUM
):
return EnumTypeData.from_json(
dpcode, getattr(self.device, key)[dpcode].values
)
if (
dptype == DPType.INTEGER
and getattr(self.device, key)[dpcode].type == DPType.INTEGER
):
return IntegerTypeData.from_json(
dpcode, getattr(self.device, key)[dpcode].values
)
if dptype not in (DPType.ENUM, DPType.INTEGER):
return dpcode
return None
def get_dptype(
self, dpcode: DPCode | None, prefer_function: bool = False
) -> DPType | None:
"""Find a matching DPCode data type available on for this device."""
if dpcode is None:
return None
order = ["status_range", "function"]
if prefer_function:
order = ["function", "status_range"]
for key in order:
if dpcode in getattr(self.device, key):
return DPType(getattr(self.device, key)[dpcode].type)
return None
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass.""" """Call when entity is added to hass."""
self.async_on_remove( self.async_on_remove(

View file

@ -31,8 +31,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeAssistantTuyaData from . import HomeAssistantTuyaData
from .base import EnumTypeData, IntegerTypeData, TuyaEntity from .base import IntegerTypeData, TuyaEntity
from .const import DOMAIN, LOGGER, TUYA_DISCOVERY_NEW, DPCode from .const import DOMAIN, LOGGER, TUYA_DISCOVERY_NEW, DPCode, DPType
TUYA_HVAC_TO_HA = { TUYA_HVAC_TO_HA = {
"auto": HVAC_MODE_HEAT_COOL, "auto": HVAC_MODE_HEAT_COOL,
@ -114,18 +114,14 @@ async def async_setup_entry(
class TuyaClimateEntity(TuyaEntity, ClimateEntity): class TuyaClimateEntity(TuyaEntity, ClimateEntity):
"""Tuya Climate Device.""" """Tuya Climate Device."""
_current_humidity_dpcode: DPCode | None = None _current_humidity: IntegerTypeData | None = None
_current_humidity_type: IntegerTypeData | None = None _current_temperature: IntegerTypeData | None = None
_current_temperature_dpcode: DPCode | None = None
_current_temperature_type: IntegerTypeData | None = None
_hvac_to_tuya: dict[str, str] _hvac_to_tuya: dict[str, str]
_set_humidity_dpcode: DPCode | None = None _set_humidity: IntegerTypeData | None = None
_set_humidity_type: IntegerTypeData | None = None _set_temperature: IntegerTypeData | None = None
_set_temperature_dpcode: DPCode | None = None
_set_temperature_type: IntegerTypeData | None = None
entity_description: TuyaClimateEntityDescription entity_description: TuyaClimateEntityDescription
def __init__( # noqa: C901 def __init__(
self, self,
device: TuyaDevice, device: TuyaDevice,
device_manager: TuyaDeviceManager, device_manager: TuyaDeviceManager,
@ -140,160 +136,117 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
# If both temperature values for celsius and fahrenheit are present, # If both temperature values for celsius and fahrenheit are present,
# use whatever the device is set to, with a fallback to celsius. # use whatever the device is set to, with a fallback to celsius.
prefered_temperature_unit = None
if all( if all(
dpcode in device.status dpcode in device.status
for dpcode in (DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F) for dpcode in (DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F)
) or all( ) or all(
dpcode in device.status for dpcode in (DPCode.TEMP_SET, DPCode.TEMP_SET_F) dpcode in device.status for dpcode in (DPCode.TEMP_SET, DPCode.TEMP_SET_F)
): ):
self._attr_temperature_unit = TEMP_CELSIUS prefered_temperature_unit = TEMP_CELSIUS
if any( if any(
"f" in device.status[dpcode].lower() "f" in device.status[dpcode].lower()
for dpcode in (DPCode.C_F, DPCode.TEMP_UNIT_CONVERT) for dpcode in (DPCode.C_F, DPCode.TEMP_UNIT_CONVERT)
if isinstance(device.status.get(dpcode), str) if isinstance(device.status.get(dpcode), str)
): ):
self._attr_temperature_unit = TEMP_FAHRENHEIT prefered_temperature_unit = TEMP_FAHRENHEIT
# If any DPCode handling celsius is present, use celsius. # Default to Celsius
elif any( self._attr_temperature_unit = TEMP_CELSIUS
dpcode in device.status for dpcode in (DPCode.TEMP_CURRENT, DPCode.TEMP_SET)
):
self._attr_temperature_unit = TEMP_CELSIUS
# If any DPCode handling fahrenheit is present, use celsius. # Figure out current temperature, use preferred unit or what is available
elif any( celsius_type = self.find_dpcode(DPCode.TEMP_CURRENT, dptype=DPType.INTEGER)
dpcode in device.status farhenheit_type = self.find_dpcode(DPCode.TEMP_CURRENT_F, dptype=DPType.INTEGER)
for dpcode in (DPCode.TEMP_CURRENT_F, DPCode.TEMP_SET_F) if farhenheit_type and (
prefered_temperature_unit == TEMP_FAHRENHEIT
or (prefered_temperature_unit == TEMP_CELSIUS and not celsius_type)
): ):
self._attr_temperature_unit = TEMP_FAHRENHEIT self._attr_temperature_unit = TEMP_FAHRENHEIT
self._current_temperature = farhenheit_type
elif celsius_type:
self._attr_temperature_unit = TEMP_CELSIUS
self._current_temperature = celsius_type
# Determine dpcode to use for setting temperature # Figure out setting temperature, use preferred unit or what is available
if all( celsius_type = self.find_dpcode(
dpcode in device.status for dpcode in (DPCode.TEMP_SET, DPCode.TEMP_SET_F) DPCode.TEMP_SET, dptype=DPType.INTEGER, prefer_function=True
)
farhenheit_type = self.find_dpcode(
DPCode.TEMP_SET_F, dptype=DPType.INTEGER, prefer_function=True
)
if farhenheit_type and (
prefered_temperature_unit == TEMP_FAHRENHEIT
or (prefered_temperature_unit == TEMP_CELSIUS and not celsius_type)
): ):
self._set_temperature_dpcode = DPCode.TEMP_SET self._set_temperature = farhenheit_type
if self._attr_temperature_unit == TEMP_FAHRENHEIT: elif celsius_type:
self._set_temperature_dpcode = DPCode.TEMP_SET_F self._set_temperature = celsius_type
elif DPCode.TEMP_SET in device.status:
self._set_temperature_dpcode = DPCode.TEMP_SET
elif DPCode.TEMP_SET_F in device.status:
self._set_temperature_dpcode = DPCode.TEMP_SET_F
# Get integer type data for the dpcode to set temperature, use # Get integer type data for the dpcode to set temperature, use
# it to define min, max & step temperatures # it to define min, max & step temperatures
if ( if self._set_temperature:
self._set_temperature_dpcode
and self._set_temperature_dpcode in device.function
):
type_data = IntegerTypeData.from_json(
device.function[self._set_temperature_dpcode].values
)
self._attr_supported_features |= SUPPORT_TARGET_TEMPERATURE self._attr_supported_features |= SUPPORT_TARGET_TEMPERATURE
self._set_temperature_type = type_data self._attr_max_temp = self._set_temperature.max_scaled
self._attr_max_temp = type_data.max_scaled self._attr_min_temp = self._set_temperature.min_scaled
self._attr_min_temp = type_data.min_scaled self._attr_target_temperature_step = self._set_temperature.step_scaled
self._attr_target_temperature_step = type_data.step_scaled
# Determine dpcode to use for getting the current temperature
if all(
dpcode in device.status
for dpcode in (DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F)
):
self._current_temperature_dpcode = DPCode.TEMP_CURRENT
if self._attr_temperature_unit == TEMP_FAHRENHEIT:
self._current_temperature_dpcode = DPCode.TEMP_CURRENT_F
elif DPCode.TEMP_CURRENT in device.status:
self._current_temperature_dpcode = DPCode.TEMP_CURRENT
elif DPCode.TEMP_CURRENT_F in device.status:
self._current_temperature_dpcode = DPCode.TEMP_CURRENT_F
# If we have a current temperature dpcode, get the integer type data
if (
self._current_temperature_dpcode
and self._current_temperature_dpcode in device.status_range
):
self._current_temperature_type = IntegerTypeData.from_json(
device.status_range[self._current_temperature_dpcode].values
)
# Determine HVAC modes # Determine HVAC modes
self._attr_hvac_modes = [] self._attr_hvac_modes = []
self._hvac_to_tuya = {} self._hvac_to_tuya = {}
if DPCode.MODE in device.function: if enum_type := self.find_dpcode(
data_type = EnumTypeData.from_json(device.function[DPCode.MODE].values) DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
):
self._attr_hvac_modes = [HVAC_MODE_OFF] self._attr_hvac_modes = [HVAC_MODE_OFF]
for tuya_mode, ha_mode in TUYA_HVAC_TO_HA.items(): for tuya_mode, ha_mode in TUYA_HVAC_TO_HA.items():
if tuya_mode in data_type.range: if tuya_mode in enum_type.range:
self._hvac_to_tuya[ha_mode] = tuya_mode self._hvac_to_tuya[ha_mode] = tuya_mode
self._attr_hvac_modes.append(ha_mode) self._attr_hvac_modes.append(ha_mode)
elif DPCode.SWITCH in device.function: elif self.find_dpcode(DPCode.SWITCH, prefer_function=True):
self._attr_hvac_modes = [ self._attr_hvac_modes = [
HVAC_MODE_OFF, HVAC_MODE_OFF,
description.switch_only_hvac_mode, description.switch_only_hvac_mode,
] ]
# Determine dpcode to use for setting the humidity # Determine dpcode to use for setting the humidity
if DPCode.HUMIDITY_SET in device.function: if int_type := self.find_dpcode(
DPCode.HUMIDITY_SET, dptype=DPType.INTEGER, prefer_function=True
):
self._attr_supported_features |= SUPPORT_TARGET_HUMIDITY self._attr_supported_features |= SUPPORT_TARGET_HUMIDITY
self._set_humidity_dpcode = DPCode.HUMIDITY_SET self._set_humidity = int_type
type_data = IntegerTypeData.from_json( self._attr_min_humidity = int(int_type.min_scaled)
device.function[DPCode.HUMIDITY_SET].values self._attr_max_humidity = int(int_type.max_scaled)
)
self._set_humidity_type = type_data
self._attr_min_humidity = int(type_data.min_scaled)
self._attr_max_humidity = int(type_data.max_scaled)
# Determine dpcode to use for getting the current humidity # Determine dpcode to use for getting the current humidity
if ( self._current_humidity = self.find_dpcode(
DPCode.HUMIDITY_CURRENT in device.status DPCode.HUMIDITY_CURRENT, dptype=DPType.INTEGER
and DPCode.HUMIDITY_CURRENT in device.status_range )
):
self._current_humidity_dpcode = DPCode.HUMIDITY_CURRENT
self._current_humidity_type = IntegerTypeData.from_json(
device.status_range[DPCode.HUMIDITY_CURRENT].values
)
# Determine dpcode to use for getting the current humidity
if (
DPCode.HUMIDITY_CURRENT in device.status
and DPCode.HUMIDITY_CURRENT in device.status_range
):
self._current_humidity_dpcode = DPCode.HUMIDITY_CURRENT
self._current_humidity_type = IntegerTypeData.from_json(
device.status_range[DPCode.HUMIDITY_CURRENT].values
)
# Determine fan modes # Determine fan modes
if ( if enum_type := self.find_dpcode(
DPCode.FAN_SPEED_ENUM in device.status DPCode.FAN_SPEED_ENUM, dptype=DPType.ENUM, prefer_function=True
and DPCode.FAN_SPEED_ENUM in device.function
): ):
self._attr_supported_features |= SUPPORT_FAN_MODE self._attr_supported_features |= SUPPORT_FAN_MODE
self._attr_fan_modes = EnumTypeData.from_json( self._attr_fan_modes = enum_type.range
device.status_range[DPCode.FAN_SPEED_ENUM].values
).range
# Determine swing modes # Determine swing modes
if any( if self.find_dpcode(
dpcode in device.function (
for dpcode in (
DPCode.SHAKE, DPCode.SHAKE,
DPCode.SWING, DPCode.SWING,
DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_HORIZONTAL,
DPCode.SWITCH_VERTICAL, DPCode.SWITCH_VERTICAL,
) ),
prefer_function=True,
): ):
self._attr_supported_features |= SUPPORT_SWING_MODE self._attr_supported_features |= SUPPORT_SWING_MODE
self._attr_swing_modes = [SWING_OFF] self._attr_swing_modes = [SWING_OFF]
if any( if self.find_dpcode((DPCode.SHAKE, DPCode.SWING), prefer_function=True):
dpcode in device.function for dpcode in (DPCode.SHAKE, DPCode.SWING)
):
self._attr_swing_modes.append(SWING_ON) self._attr_swing_modes.append(SWING_ON)
if DPCode.SWITCH_HORIZONTAL in device.function: if self.find_dpcode(DPCode.SWITCH_HORIZONTAL, prefer_function=True):
self._attr_swing_modes.append(SWING_HORIZONTAL) self._attr_swing_modes.append(SWING_HORIZONTAL)
if DPCode.SWITCH_VERTICAL in device.function: if self.find_dpcode(DPCode.SWITCH_VERTICAL, prefer_function=True):
self._attr_swing_modes.append(SWING_VERTICAL) self._attr_swing_modes.append(SWING_VERTICAL)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
@ -301,9 +254,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
await super().async_added_to_hass() await super().async_added_to_hass()
# Log unknown modes # Log unknown modes
if DPCode.MODE in self.device.function: if enum_type := self.find_dpcode(
data_type = EnumTypeData.from_json(self.device.function[DPCode.MODE].values) DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
for tuya_mode in data_type.range: ):
for tuya_mode in enum_type.range:
if tuya_mode not in TUYA_HVAC_TO_HA: if tuya_mode not in TUYA_HVAC_TO_HA:
LOGGER.warning( LOGGER.warning(
"Unknown HVAC mode '%s' for device %s; assuming it as off", "Unknown HVAC mode '%s' for device %s; assuming it as off",
@ -326,7 +280,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
def set_humidity(self, humidity: float) -> None: def set_humidity(self, humidity: float) -> None:
"""Set new target humidity.""" """Set new target humidity."""
if self._set_humidity_dpcode is None or self._set_humidity_type is None: if self._set_humidity is None:
raise RuntimeError( raise RuntimeError(
"Cannot set humidity, device doesn't provide methods to set it" "Cannot set humidity, device doesn't provide methods to set it"
) )
@ -334,8 +288,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._send_command( self._send_command(
[ [
{ {
"code": self._set_humidity_dpcode, "code": self._set_humidity.dpcode,
"value": self._set_humidity_type.scale_value_back(humidity), "value": self._set_humidity.scale_value_back(humidity),
} }
] ]
) )
@ -367,7 +321,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
def set_temperature(self, **kwargs: Any) -> None: def set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature.""" """Set new target temperature."""
if self._set_temperature_dpcode is None or self._set_temperature_type is None: if self._set_temperature is None:
raise RuntimeError( raise RuntimeError(
"Cannot set target temperature, device doesn't provide methods to set it" "Cannot set target temperature, device doesn't provide methods to set it"
) )
@ -375,11 +329,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._send_command( self._send_command(
[ [
{ {
"code": self._set_temperature_dpcode, "code": self._set_temperature.dpcode,
"value": round( "value": round(
self._set_temperature_type.scale_value_back( self._set_temperature.scale_value_back(kwargs["temperature"])
kwargs["temperature"]
)
), ),
} }
] ]
@ -388,53 +340,50 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
@property @property
def current_temperature(self) -> float | None: def current_temperature(self) -> float | None:
"""Return the current temperature.""" """Return the current temperature."""
if ( if self._current_temperature is None:
self._current_temperature_dpcode is None
or self._current_temperature_type is None
):
return None return None
temperature = self.device.status.get(self._current_temperature_dpcode) temperature = self.device.status.get(self._current_temperature.dpcode)
if temperature is None: if temperature is None:
return None return None
return self._current_temperature_type.scale_value(temperature) return self._current_temperature.scale_value(temperature)
@property @property
def current_humidity(self) -> int | None: def current_humidity(self) -> int | None:
"""Return the current humidity.""" """Return the current humidity."""
if self._current_humidity_dpcode is None or self._current_humidity_type is None: if self._current_humidity is None:
return None return None
humidity = self.device.status.get(self._current_humidity_dpcode) humidity = self.device.status.get(self._current_humidity.dpcode)
if humidity is None: if humidity is None:
return None return None
return round(self._current_humidity_type.scale_value(humidity)) return round(self._current_humidity.scale_value(humidity))
@property @property
def target_temperature(self) -> float | None: def target_temperature(self) -> float | None:
"""Return the temperature currently set to be reached.""" """Return the temperature currently set to be reached."""
if self._set_temperature_dpcode is None or self._set_temperature_type is None: if self._set_temperature is None:
return None return None
temperature = self.device.status.get(self._set_temperature_dpcode) temperature = self.device.status.get(self._set_temperature.dpcode)
if temperature is None: if temperature is None:
return None return None
return self._set_temperature_type.scale_value(temperature) return self._set_temperature.scale_value(temperature)
@property @property
def target_humidity(self) -> int | None: def target_humidity(self) -> int | None:
"""Return the humidity currently set to be reached.""" """Return the humidity currently set to be reached."""
if self._set_humidity_dpcode is None or self._set_humidity_type is None: if self._set_humidity is None:
return None return None
humidity = self.device.status.get(self._set_humidity_dpcode) humidity = self.device.status.get(self._set_humidity.dpcode)
if humidity is None: if humidity is None:
return None return None
return round(self._set_humidity_type.scale_value(humidity)) return round(self._set_humidity.scale_value(humidity))
@property @property
def hvac_mode(self) -> str: def hvac_mode(self) -> str:

View file

@ -112,6 +112,17 @@ class WorkMode(StrEnum):
WHITE = "white" WHITE = "white"
class DPType(StrEnum):
"""Data point types."""
BOOLEAN = "Boolean"
ENUM = "Enum"
INTEGER = "Integer"
JSON = "Json"
RAW = "Raw"
STRING = "String"
class DPCode(StrEnum): class DPCode(StrEnum):
"""Data Point Codes used by Tuya. """Data Point Codes used by Tuya.

View file

@ -24,8 +24,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeAssistantTuyaData from . import HomeAssistantTuyaData
from .base import EnumTypeData, IntegerTypeData, TuyaEntity from .base import IntegerTypeData, TuyaEntity
from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType
@dataclass @dataclass
@ -177,11 +177,9 @@ async def async_setup_entry(
class TuyaCoverEntity(TuyaEntity, CoverEntity): class TuyaCoverEntity(TuyaEntity, CoverEntity):
"""Tuya Cover Device.""" """Tuya Cover Device."""
_current_position_type: IntegerTypeData | None = None _current_position: IntegerTypeData | None = None
_set_position_type: IntegerTypeData | None = None _set_position: IntegerTypeData | None = None
_tilt_dpcode: DPCode | None = None _tilt: IntegerTypeData | None = None
_tilt_type: IntegerTypeData | None = None
_position_dpcode: DPCode | None = None
entity_description: TuyaCoverEntityDescription entity_description: TuyaCoverEntityDescription
def __init__( def __init__(
@ -197,85 +195,54 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._attr_supported_features = 0 self._attr_supported_features = 0
# Check if this cover is based on a switch or has controls # Check if this cover is based on a switch or has controls
if device.function[description.key].type == "Boolean": if self.find_dpcode(description.key, prefer_function=True):
self._attr_supported_features |= SUPPORT_OPEN | SUPPORT_CLOSE if device.function[description.key].type == "Boolean":
elif device.function[description.key].type == "Enum": self._attr_supported_features |= SUPPORT_OPEN | SUPPORT_CLOSE
data_type = EnumTypeData.from_json(device.function[description.key].values) elif enum_type := self.find_dpcode(
if description.open_instruction_value in data_type.range: description.key, dptype=DPType.ENUM, prefer_function=True
self._attr_supported_features |= SUPPORT_OPEN ):
if description.close_instruction_value in data_type.range: if description.open_instruction_value in enum_type.range:
self._attr_supported_features |= SUPPORT_CLOSE self._attr_supported_features |= SUPPORT_OPEN
if description.stop_instruction_value in data_type.range: if description.close_instruction_value in enum_type.range:
self._attr_supported_features |= SUPPORT_STOP self._attr_supported_features |= SUPPORT_CLOSE
if description.stop_instruction_value in enum_type.range:
self._attr_supported_features |= SUPPORT_STOP
# Determine type to use for setting the position # Determine type to use for setting the position
if ( if int_type := self.find_dpcode(
description.set_position is not None description.set_position, dptype=DPType.INTEGER, prefer_function=True
and description.set_position in device.status_range
): ):
self._attr_supported_features |= SUPPORT_SET_POSITION self._attr_supported_features |= SUPPORT_SET_POSITION
self._set_position_type = IntegerTypeData.from_json( self._set_position = int_type
device.status_range[description.set_position].values
)
# Set as default, unless overwritten below # Set as default, unless overwritten below
self._current_position_type = self._set_position_type self._current_position = int_type
# Determine type for getting the position # Determine type for getting the position
if ( if int_type := self.find_dpcode(
description.current_position is not None description.current_position, dptype=DPType.INTEGER, prefer_function=True
and description.current_position in device.status_range
): ):
self._current_position_type = IntegerTypeData.from_json( self._current_position = int_type
device.status_range[description.current_position].values
)
# Determine type to use for setting the tilt # Determine type to use for setting the tilt
if tilt_dpcode := next( if int_type := self.find_dpcode(
( (DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL),
dpcode dptype=DPType.INTEGER,
for dpcode in (DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL) prefer_function=True,
if dpcode in device.function
),
None,
): ):
self._attr_supported_features |= SUPPORT_SET_TILT_POSITION self._attr_supported_features |= SUPPORT_SET_TILT_POSITION
self._tilt_dpcode = tilt_dpcode self._tilt = int_type
self._tilt_type = IntegerTypeData.from_json(
device.status_range[tilt_dpcode].values
)
# Determine current_position DPCodes
if (
self.entity_description.current_position is None
and self.entity_description.set_position is not None
):
self._position_dpcode = self.entity_description.set_position
elif isinstance(self.entity_description.current_position, DPCode):
self._position_dpcode = self.entity_description.current_position
elif isinstance(self.entity_description.current_position, tuple):
self._position_dpcode = next(
(
dpcode
for dpcode in self.entity_description.current_position
if self.device.status.get(dpcode) is not None
),
None,
)
@property @property
def current_cover_position(self) -> int | None: def current_cover_position(self) -> int | None:
"""Return cover current position.""" """Return cover current position."""
if self._current_position_type is None: if self._current_position is None:
return None return None
if not self._position_dpcode: if (position := self.device.status.get(self._current_position.dpcode)) is None:
return None
if (position := self.device.status.get(self._position_dpcode)) is None:
return None return None
return round( return round(
self._current_position_type.remap_value_to(position, 0, 100, reverse=True) self._current_position.remap_value_to(position, 0, 100, reverse=True)
) )
@property @property
@ -284,13 +251,13 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
None is unknown, 0 is closed, 100 is fully open. None is unknown, 0 is closed, 100 is fully open.
""" """
if self._tilt_dpcode is None or self._tilt_type is None: if self._tilt is None:
return None return None
if (angle := self.device.status.get(self._tilt_dpcode)) is None: if (angle := self.device.status.get(self._tilt.dpcode)) is None:
return None return None
return round(self._tilt_type.remap_value_to(angle, 0, 100)) return round(self._tilt.remap_value_to(angle, 0, 100))
@property @property
def is_closed(self) -> bool | None: def is_closed(self) -> bool | None:
@ -316,24 +283,21 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
def open_cover(self, **kwargs: Any) -> None: def open_cover(self, **kwargs: Any) -> None:
"""Open the cover.""" """Open the cover."""
value: bool | str = True value: bool | str = True
if self.device.function[self.entity_description.key].type == "Enum": if self.find_dpcode(
self.entity_description.key, dptype=DPType.ENUM, prefer_function=True
):
value = self.entity_description.open_instruction_value value = self.entity_description.open_instruction_value
commands: list[dict[str, str | int]] = [ commands: list[dict[str, str | int]] = [
{"code": self.entity_description.key, "value": value} {"code": self.entity_description.key, "value": value}
] ]
if ( if self._set_position is not None:
self.entity_description.set_position is not None
and self._set_position_type is not None
):
commands.append( commands.append(
{ {
"code": self.entity_description.set_position, "code": self._set_position.dpcode,
"value": round( "value": round(
self._set_position_type.remap_value_from( self._set_position.remap_value_from(100, 0, 100, reverse=True),
100, 0, 100, reverse=True
),
), ),
} }
) )
@ -343,24 +307,21 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
def close_cover(self, **kwargs: Any) -> None: def close_cover(self, **kwargs: Any) -> None:
"""Close cover.""" """Close cover."""
value: bool | str = False value: bool | str = False
if self.device.function[self.entity_description.key].type == "Enum": if self.find_dpcode(
self.entity_description.key, dptype=DPType.ENUM, prefer_function=True
):
value = self.entity_description.close_instruction_value value = self.entity_description.close_instruction_value
commands: list[dict[str, str | int]] = [ commands: list[dict[str, str | int]] = [
{"code": self.entity_description.key, "value": value} {"code": self.entity_description.key, "value": value}
] ]
if ( if self._set_position is not None:
self.entity_description.set_position is not None
and self._set_position_type is not None
):
commands.append( commands.append(
{ {
"code": self.entity_description.set_position, "code": self._set_position.dpcode,
"value": round( "value": round(
self._set_position_type.remap_value_from( self._set_position.remap_value_from(0, 0, 100, reverse=True),
0, 0, 100, reverse=True
),
), ),
} }
) )
@ -369,7 +330,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
def set_cover_position(self, **kwargs: Any) -> None: def set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position.""" """Move the cover to a specific position."""
if self._set_position_type is None: if self._set_position is None:
raise RuntimeError( raise RuntimeError(
"Cannot set position, device doesn't provide methods to set it" "Cannot set position, device doesn't provide methods to set it"
) )
@ -377,9 +338,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._send_command( self._send_command(
[ [
{ {
"code": self.entity_description.set_position, "code": self._set_position.dpcode,
"value": round( "value": round(
self._set_position_type.remap_value_from( self._set_position.remap_value_from(
kwargs[ATTR_POSITION], 0, 100, reverse=True kwargs[ATTR_POSITION], 0, 100, reverse=True
) )
), ),
@ -400,7 +361,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
def set_cover_tilt_position(self, **kwargs): def set_cover_tilt_position(self, **kwargs):
"""Move the cover tilt to a specific position.""" """Move the cover tilt to a specific position."""
if self._tilt_type is None: if self._tilt is None:
raise RuntimeError( raise RuntimeError(
"Cannot set tilt, device doesn't provide methods to set it" "Cannot set tilt, device doesn't provide methods to set it"
) )
@ -408,9 +369,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._send_command( self._send_command(
[ [
{ {
"code": self._tilt_dpcode, "code": self._tilt.dpcode,
"value": round( "value": round(
self._tilt_type.remap_value_from( self._tilt.remap_value_from(
kwargs[ATTR_TILT_POSITION], 0, 100, reverse=True kwargs[ATTR_TILT_POSITION], 0, 100, reverse=True
) )
), ),

View file

@ -17,8 +17,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeAssistantTuyaData from . import HomeAssistantTuyaData
from .base import EnumTypeData, IntegerTypeData, TuyaEntity from .base import IntegerTypeData, TuyaEntity
from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType
@dataclass @dataclass
@ -79,7 +79,7 @@ async def async_setup_entry(
class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
"""Tuya (de)humidifier Device.""" """Tuya (de)humidifier Device."""
_set_humidity_type: IntegerTypeData | None = None _set_humidity: IntegerTypeData | None = None
_switch_dpcode: DPCode | None = None _switch_dpcode: DPCode | None = None
entity_description: TuyaHumidifierEntityDescription entity_description: TuyaHumidifierEntityDescription
@ -96,30 +96,24 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self._attr_supported_features = 0 self._attr_supported_features = 0
# Determine main switch DPCode # Determine main switch DPCode
possible_dpcodes = description.dpcode or description.key self._switch_dpcode = self.find_dpcode(
if isinstance(possible_dpcodes, DPCode) and possible_dpcodes in device.function: description.dpcode or DPCode(description.key), prefer_function=True
self._switch_dpcode = possible_dpcodes )
elif isinstance(possible_dpcodes, tuple):
self._switch_dpcode = next(
(dpcode for dpcode in possible_dpcodes if dpcode in device.function),
None,
)
# Determine humidity parameters # Determine humidity parameters
if description.humidity in device.status_range: if int_type := self.find_dpcode(
type_data = IntegerTypeData.from_json( description.humidity, dptype=DPType.INTEGER, prefer_function=True
device.status_range[description.humidity].values ):
) self._set_humiditye = int_type
self._set_humidity_type = type_data self._attr_min_humidity = int(int_type.min_scaled)
self._attr_min_humidity = int(type_data.min_scaled) self._attr_max_humidity = int(int_type.max_scaled)
self._attr_max_humidity = int(type_data.max_scaled)
# Determine mode support and provided modes # Determine mode support and provided modes
if DPCode.MODE in device.function: if enum_type := self.find_dpcode(
DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
):
self._attr_supported_features |= SUPPORT_MODES self._attr_supported_features |= SUPPORT_MODES
self._attr_available_modes = EnumTypeData.from_json( self._attr_available_modes = enum_type.range
device.function[DPCode.MODE].values
).range
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
@ -136,14 +130,14 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
@property @property
def target_humidity(self) -> int | None: def target_humidity(self) -> int | None:
"""Return the humidity we try to reach.""" """Return the humidity we try to reach."""
if self._set_humidity_type is None: if self._set_humidity is None:
return None return None
humidity = self.device.status.get(self.entity_description.humidity) humidity = self.device.status.get(self._set_humidity.dpcode)
if humidity is None: if humidity is None:
return None return None
return round(self._set_humidity_type.scale_value(humidity)) return round(self._set_humidity.scale_value(humidity))
def turn_on(self, **kwargs): def turn_on(self, **kwargs):
"""Turn the device on.""" """Turn the device on."""
@ -155,7 +149,7 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
def set_humidity(self, humidity): def set_humidity(self, humidity):
"""Set new target humidity.""" """Set new target humidity."""
if self._set_humidity_type is None: if self._set_humidity is None:
raise RuntimeError( raise RuntimeError(
"Cannot set humidity, device doesn't provide methods to set it" "Cannot set humidity, device doesn't provide methods to set it"
) )
@ -163,8 +157,8 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
self._send_command( self._send_command(
[ [
{ {
"code": self.entity_description.humidity, "code": self._set_humidity.dpcode,
"value": self._set_humidity_type.scale_value_back(humidity), "value": self._set_humidity.scale_value_back(humidity),
} }
] ]
) )

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
import json import json
from typing import Any from typing import Any, cast
from tuya_iot import TuyaDevice, TuyaDeviceManager from tuya_iot import TuyaDevice, TuyaDeviceManager
@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeAssistantTuyaData from . import HomeAssistantTuyaData
from .base import IntegerTypeData, TuyaEntity from .base import IntegerTypeData, TuyaEntity
from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, WorkMode from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode
from .util import remap_value from .util import remap_value
@ -40,15 +40,15 @@ class ColorTypeData:
DEFAULT_COLOR_TYPE_DATA = ColorTypeData( DEFAULT_COLOR_TYPE_DATA = ColorTypeData(
h_type=IntegerTypeData(min=1, scale=0, max=360, step=1), h_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1),
s_type=IntegerTypeData(min=1, scale=0, max=255, step=1), s_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1),
v_type=IntegerTypeData(min=1, scale=0, max=255, step=1), v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=255, step=1),
) )
DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData( DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData(
h_type=IntegerTypeData(min=1, scale=0, max=360, step=1), h_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=360, step=1),
s_type=IntegerTypeData(min=1, scale=0, max=1000, step=1), s_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1),
v_type=IntegerTypeData(min=1, scale=0, max=1000, step=1), v_type=IntegerTypeData(DPCode.COLOUR_DATA_HSV, min=1, scale=0, max=1000, step=1),
) )
@ -323,15 +323,14 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
"""Tuya light device.""" """Tuya light device."""
entity_description: TuyaLightEntityDescription entity_description: TuyaLightEntityDescription
_brightness_dpcode: DPCode | None = None
_brightness_max_type: IntegerTypeData | None = None _brightness_max: IntegerTypeData | None = None
_brightness_min_type: IntegerTypeData | None = None _brightness_min: IntegerTypeData | None = None
_brightness_type: IntegerTypeData | None = None _brightness: IntegerTypeData | None = None
_color_data_dpcode: DPCode | None = None _color_data_dpcode: DPCode | None = None
_color_data_type: ColorTypeData | None = None _color_data_type: ColorTypeData | None = None
_color_mode_dpcode: DPCode | None = None _color_mode: DPCode | None = None
_color_temp_dpcode: DPCode | None = None _color_temp: IntegerTypeData | None = None
_color_temp_type: IntegerTypeData | None = None
def __init__( def __init__(
self, self,
@ -345,106 +344,51 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
self._attr_unique_id = f"{super().unique_id}{description.key}" self._attr_unique_id = f"{super().unique_id}{description.key}"
self._attr_supported_color_modes = {COLOR_MODE_ONOFF} self._attr_supported_color_modes = {COLOR_MODE_ONOFF}
# Determine brightness DPCodes # Determine DPCodes
if ( self._color_mode_dpcode = self.find_dpcode(
isinstance(description.brightness, DPCode) description.color_mode, prefer_function=True
and description.brightness in device.function )
):
self._brightness_dpcode = description.brightness
elif isinstance(description.brightness, tuple):
self._brightness_dpcode = next(
(
dpcode
for dpcode in description.brightness
if dpcode in device.function
),
None,
)
# Determine color mode DPCode if int_type := self.find_dpcode(
if ( description.brightness, dptype=DPType.INTEGER, prefer_function=True
description.color_mode is not None
and description.color_mode in device.function
): ):
self._color_mode_dpcode = description.color_mode self._brightness = int_type
# Determine DPCodes for color temperature
if (
isinstance(description.color_temp, DPCode)
and description.color_temp in device.function
):
self._color_temp_dpcode = description.color_temp
elif isinstance(description.color_temp, tuple):
self._color_temp_dpcode = next(
(
dpcode
for dpcode in description.color_temp
if dpcode in device.function
),
None,
)
# Determine DPCodes for color data
if (
isinstance(description.color_data, DPCode)
and description.color_data in device.function
):
self._color_data_dpcode = description.color_data
elif isinstance(description.color_data, tuple):
self._color_data_dpcode = next(
(
dpcode
for dpcode in description.color_data
if dpcode in device.function
),
None,
)
# Update internals based on found brightness dpcode
if self._brightness_dpcode:
self._attr_supported_color_modes.add(COLOR_MODE_BRIGHTNESS) self._attr_supported_color_modes.add(COLOR_MODE_BRIGHTNESS)
self._brightness_type = IntegerTypeData.from_json( self._brightness_max = self.find_dpcode(
device.function[self._brightness_dpcode].values description.brightness_max, dptype=DPType.INTEGER
)
self._brightness_min = self.find_dpcode(
description.brightness_min, dptype=DPType.INTEGER
) )
# Check if min/max capable if int_type := self.find_dpcode(
if ( description.color_temp, dptype=DPType.INTEGER, prefer_function=True
description.brightness_max is not None ):
and description.brightness_min is not None self._color_temp = int_type
and description.brightness_max in device.function
and description.brightness_min in device.function
):
self._brightness_max_type = IntegerTypeData.from_json(
device.function[description.brightness_max].values
)
self._brightness_min_type = IntegerTypeData.from_json(
device.function[description.brightness_min].values
)
# Update internals based on found color temperature dpcode
if self._color_temp_dpcode:
self._attr_supported_color_modes.add(COLOR_MODE_COLOR_TEMP) self._attr_supported_color_modes.add(COLOR_MODE_COLOR_TEMP)
self._color_temp_type = IntegerTypeData.from_json(
device.function[self._color_temp_dpcode].values
)
# Update internals based on found color data dpcode if (
if self._color_data_dpcode: dpcode := self.find_dpcode(description.color_data, prefer_function=True)
) and self.get_dptype(dpcode) == DPType.JSON:
self._color_data_dpcode = dpcode
self._attr_supported_color_modes.add(COLOR_MODE_HS) self._attr_supported_color_modes.add(COLOR_MODE_HS)
if dpcode in self.device.function:
values = cast(str, self.device.function[dpcode].values)
else:
values = self.device.status_range[dpcode].values
# Fetch color data type information # Fetch color data type information
if function_data := json.loads( if function_data := json.loads(values):
self.device.function[self._color_data_dpcode].values
):
self._color_data_type = ColorTypeData( self._color_data_type = ColorTypeData(
h_type=IntegerTypeData(**function_data["h"]), h_type=IntegerTypeData(dpcode, **function_data["h"]),
s_type=IntegerTypeData(**function_data["s"]), s_type=IntegerTypeData(dpcode, **function_data["s"]),
v_type=IntegerTypeData(**function_data["v"]), v_type=IntegerTypeData(dpcode, **function_data["v"]),
) )
else: else:
# If no type is found, use a default one # If no type is found, use a default one
self._color_data_type = self.entity_description.default_color_type self._color_data_type = self.entity_description.default_color_type
if self._color_data_dpcode == DPCode.COLOUR_DATA_V2 or ( if self._color_data_dpcode == DPCode.COLOUR_DATA_V2 or (
self._brightness_type and self._brightness_type.max > 255 self._brightness and self._brightness.max > 255
): ):
self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2 self._color_data_type = DEFAULT_COLOR_TYPE_DATA_V2
@ -457,7 +401,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
"""Turn on or control the light.""" """Turn on or control the light."""
commands = [{"code": self.entity_description.key, "value": True}] commands = [{"code": self.entity_description.key, "value": True}]
if self._color_temp_type and ATTR_COLOR_TEMP in kwargs: if self._color_temp and ATTR_COLOR_TEMP in kwargs:
if self._color_mode_dpcode: if self._color_mode_dpcode:
commands += [ commands += [
{ {
@ -468,9 +412,9 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
commands += [ commands += [
{ {
"code": self._color_temp_dpcode, "code": self._color_temp.dpcode,
"value": round( "value": round(
self._color_temp_type.remap_value_from( self._color_temp.remap_value_from(
kwargs[ATTR_COLOR_TEMP], kwargs[ATTR_COLOR_TEMP],
self.min_mireds, self.min_mireds,
self.max_mireds, self.max_mireds,
@ -525,37 +469,31 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
if ( if (
ATTR_BRIGHTNESS in kwargs ATTR_BRIGHTNESS in kwargs
and self.color_mode != COLOR_MODE_HS and self.color_mode != COLOR_MODE_HS
and self._brightness_type and self._brightness
): ):
brightness = kwargs[ATTR_BRIGHTNESS] brightness = kwargs[ATTR_BRIGHTNESS]
# If there is a min/max value, the brightness is actually limited. # If there is a min/max value, the brightness is actually limited.
# Meaning it is actually not on a 0-255 scale. # Meaning it is actually not on a 0-255 scale.
if ( if (
self._brightness_max_type is not None self._brightness_max is not None
and self._brightness_min_type is not None and self._brightness_min is not None
and self.entity_description.brightness_max is not None
and self.entity_description.brightness_min is not None
and ( and (
brightness_max := self.device.status.get( brightness_max := self.device.status.get(
self.entity_description.brightness_max self._brightness_max.dpcode
) )
) )
is not None is not None
and ( and (
brightness_min := self.device.status.get( brightness_min := self.device.status.get(
self.entity_description.brightness_min self._brightness_min.dpcode
) )
) )
is not None is not None
): ):
# Remap values onto our scale # Remap values onto our scale
brightness_max = self._brightness_max_type.remap_value_to( brightness_max = self._brightness_max.remap_value_to(brightness_max)
brightness_max brightness_min = self._brightness_min.remap_value_to(brightness_min)
)
brightness_min = self._brightness_min_type.remap_value_to(
brightness_min
)
# Remap the brightness value from their min-max to our 0-255 scale # Remap the brightness value from their min-max to our 0-255 scale
brightness = remap_value( brightness = remap_value(
@ -566,8 +504,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
commands += [ commands += [
{ {
"code": self._brightness_dpcode, "code": self._brightness.dpcode,
"value": round(self._brightness_type.remap_value_from(brightness)), "value": round(self._brightness.remap_value_from(brightness)),
}, },
] ]
@ -584,39 +522,29 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
if self.color_mode == COLOR_MODE_HS and (color_data := self._get_color_data()): if self.color_mode == COLOR_MODE_HS and (color_data := self._get_color_data()):
return color_data.brightness return color_data.brightness
if not self._brightness_dpcode or not self._brightness_type: if not self._brightness:
return None return None
brightness = self.device.status.get(self._brightness_dpcode) brightness = self.device.status.get(self._brightness.dpcode)
if brightness is None: if brightness is None:
return None return None
# Remap value to our scale # Remap value to our scale
brightness = self._brightness_type.remap_value_to(brightness) brightness = self._brightness.remap_value_to(brightness)
# If there is a min/max value, the brightness is actually limited. # If there is a min/max value, the brightness is actually limited.
# Meaning it is actually not on a 0-255 scale. # Meaning it is actually not on a 0-255 scale.
if ( if (
self._brightness_max_type is not None self._brightness_max is not None
and self._brightness_min_type is not None and self._brightness_min is not None
and self.entity_description.brightness_max is not None and (brightness_max := self.device.status.get(self._brightness_max.dpcode))
and self.entity_description.brightness_min is not None
and (
brightness_max := self.device.status.get(
self.entity_description.brightness_max
)
)
is not None is not None
and ( and (brightness_min := self.device.status.get(self._brightness_min.dpcode))
brightness_min := self.device.status.get(
self.entity_description.brightness_min
)
)
is not None is not None
): ):
# Remap values onto our scale # Remap values onto our scale
brightness_max = self._brightness_max_type.remap_value_to(brightness_max) brightness_max = self._brightness_max.remap_value_to(brightness_max)
brightness_min = self._brightness_min_type.remap_value_to(brightness_min) brightness_min = self._brightness_min.remap_value_to(brightness_min)
# Remap the brightness value from their min-max to our 0-255 scale # Remap the brightness value from their min-max to our 0-255 scale
brightness = remap_value( brightness = remap_value(
@ -630,15 +558,15 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
@property @property
def color_temp(self) -> int | None: def color_temp(self) -> int | None:
"""Return the color_temp of the light.""" """Return the color_temp of the light."""
if not self._color_temp_dpcode or not self._color_temp_type: if not self._color_temp:
return None return None
temperature = self.device.status.get(self._color_temp_dpcode) temperature = self.device.status.get(self._color_temp.dpcode)
if temperature is None: if temperature is None:
return None return None
return round( return round(
self._color_temp_type.remap_value_to( self._color_temp.remap_value_to(
temperature, self.min_mireds, self.max_mireds, reverse=True temperature, self.min_mireds, self.max_mireds, reverse=True
) )
) )
@ -662,9 +590,9 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
and self.device.status.get(self._color_mode_dpcode) != WorkMode.WHITE and self.device.status.get(self._color_mode_dpcode) != WorkMode.WHITE
): ):
return COLOR_MODE_HS return COLOR_MODE_HS
if self._color_temp_dpcode: if self._color_temp:
return COLOR_MODE_COLOR_TEMP return COLOR_MODE_COLOR_TEMP
if self._brightness_dpcode: if self._brightness:
return COLOR_MODE_BRIGHTNESS return COLOR_MODE_BRIGHTNESS
return COLOR_MODE_ONOFF return COLOR_MODE_ONOFF

View file

@ -1,10 +1,7 @@
"""Support for Tuya number.""" """Support for Tuya number."""
from __future__ import annotations from __future__ import annotations
from typing import cast
from tuya_iot import TuyaDevice, TuyaDeviceManager from tuya_iot import TuyaDevice, TuyaDeviceManager
from tuya_iot.device import TuyaDeviceStatusRange
from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -15,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeAssistantTuyaData from . import HomeAssistantTuyaData
from .base import IntegerTypeData, TuyaEntity from .base import IntegerTypeData, TuyaEntity
from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType
# 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.
@ -280,8 +277,7 @@ async def async_setup_entry(
class TuyaNumberEntity(TuyaEntity, NumberEntity): class TuyaNumberEntity(TuyaEntity, NumberEntity):
"""Tuya Number Entity.""" """Tuya Number Entity."""
_status_range: TuyaDeviceStatusRange | None = None _number: IntegerTypeData | None = None
_type_data: IntegerTypeData | None = None
def __init__( def __init__(
self, self,
@ -294,45 +290,39 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}" self._attr_unique_id = f"{super().unique_id}{description.key}"
if status_range := device.status_range.get(description.key): if int_type := self.find_dpcode(
self._status_range = cast(TuyaDeviceStatusRange, status_range) description.key, dptype=DPType.INTEGER, prefer_function=True
):
# Extract type data from integer status range, self._number = int_type
# and determine unit of measurement self._attr_max_value = self._number.max_scaled
if self._status_range.type == "Integer": self._attr_min_value = self._number.min_scaled
self._type_data = IntegerTypeData.from_json(self._status_range.values) self._attr_step = self._number.step_scaled
self._attr_max_value = self._type_data.max_scaled if description.unit_of_measurement is None:
self._attr_min_value = self._type_data.min_scaled self._attr_unit_of_measurement = self._number.unit
self._attr_step = self._type_data.step_scaled
if description.unit_of_measurement is None:
self._attr_unit_of_measurement = self._type_data.unit
@property @property
def value(self) -> float | None: def value(self) -> float | None:
"""Return the entity value to represent the entity state.""" """Return the entity value to represent the entity state."""
# Unknown or unsupported data type # Unknown or unsupported data type
if self._status_range is None or self._status_range.type != "Integer": if self._number is None:
return None return None
# Raw value # Raw value
value = self.device.status.get(self.entity_description.key) if not (value := self.device.status.get(self.entity_description.key)):
return None
# Scale integer/float value return self._number.scale_value(value)
if value is not None and isinstance(self._type_data, IntegerTypeData):
return self._type_data.scale_value(value)
return None
def set_value(self, value: float) -> None: def set_value(self, value: float) -> None:
"""Set new value.""" """Set new value."""
if self._type_data is None: if self._number is None:
raise RuntimeError("Cannot set value, device doesn't provide type data") raise RuntimeError("Cannot set value, device doesn't provide type data")
self._send_command( self._send_command(
[ [
{ {
"code": self.entity_description.key, "code": self.entity_description.key,
"value": self._type_data.scale_value_back(value), "value": self._number.scale_value_back(value),
} }
] ]
) )

View file

@ -1,10 +1,7 @@
"""Support for Tuya select.""" """Support for Tuya select."""
from __future__ import annotations from __future__ import annotations
from typing import cast
from tuya_iot import TuyaDevice, TuyaDeviceManager from tuya_iot import TuyaDevice, TuyaDeviceManager
from tuya_iot.device import TuyaDeviceStatusRange
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -14,8 +11,8 @@ from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeAssistantTuyaData from . import HomeAssistantTuyaData
from .base import EnumTypeData, TuyaEntity from .base import TuyaEntity
from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, TuyaDeviceClass from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType, TuyaDeviceClass
# All descriptions can be found here. Mostly the Enum data types in the # All descriptions can be found here. Mostly the Enum data types in the
# default instructions set of each category end up being a select. # default instructions set of each category end up being a select.
@ -287,13 +284,10 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity):
self._attr_unique_id = f"{super().unique_id}{description.key}" self._attr_unique_id = f"{super().unique_id}{description.key}"
self._attr_opions: list[str] = [] self._attr_opions: list[str] = []
if status_range := device.status_range.get(description.key): if enum_type := self.find_dpcode(
self._status_range = cast(TuyaDeviceStatusRange, status_range) description.key, dptype=DPType.ENUM, prefer_function=True
):
# Extract type data from enum status range, self._attr_options = enum_type.range
if self._status_range.type == "Enum":
type_data = EnumTypeData.from_json(self._status_range.values)
self._attr_options = type_data.range
@property @property
def current_option(self) -> str | None: def current_option(self) -> str | None:

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import cast
from tuya_iot import TuyaDevice, TuyaDeviceManager from tuya_iot import TuyaDevice, TuyaDeviceManager
from tuya_iot.device import TuyaDeviceStatusRange from tuya_iot.device import TuyaDeviceStatusRange
@ -33,6 +32,7 @@ from .const import (
DOMAIN, DOMAIN,
TUYA_DISCOVERY_NEW, TUYA_DISCOVERY_NEW,
DPCode, DPCode,
DPType,
TuyaDeviceClass, TuyaDeviceClass,
UnitOfMeasurement, UnitOfMeasurement,
) )
@ -776,6 +776,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
entity_description: TuyaSensorEntityDescription entity_description: TuyaSensorEntityDescription
_status_range: TuyaDeviceStatusRange | None = None _status_range: TuyaDeviceStatusRange | None = None
_type: DPType | None = None
_type_data: IntegerTypeData | EnumTypeData | None = None _type_data: IntegerTypeData | EnumTypeData | None = None
_uom: UnitOfMeasurement | None = None _uom: UnitOfMeasurement | None = None
@ -792,19 +793,18 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
f"{super().unique_id}{description.key}{description.subkey or ''}" f"{super().unique_id}{description.key}{description.subkey or ''}"
) )
if status_range := device.status_range.get(description.key): if int_type := self.find_dpcode(description.key, dptype=DPType.INTEGER):
self._status_range = cast(TuyaDeviceStatusRange, status_range) self._type_data = int_type
self._type = DPType.INTEGER
# Extract type data from integer status range, if description.native_unit_of_measurement is None:
# and determine unit of measurement self._attr_native_unit_of_measurement = int_type.unit
if self._status_range.type == "Integer": elif enum_type := self.find_dpcode(
self._type_data = IntegerTypeData.from_json(self._status_range.values) description.key, dptype=DPType.ENUM, prefer_function=True
if description.native_unit_of_measurement is None: ):
self._attr_native_unit_of_measurement = self._type_data.unit self._type_data = enum_type
self._type = DPType.ENUM
# Extract type data from enum status range else:
elif self._status_range.type == "Enum": self._type = self.get_dptype(DPCode(description.key))
self._type_data = EnumTypeData.from_json(self._status_range.values)
# Logic to ensure the set device class and API received Unit Of Measurement # Logic to ensure the set device class and API received Unit Of Measurement
# match Home Assistants requirements. # match Home Assistants requirements.
@ -841,13 +841,13 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
@property @property
def native_value(self) -> StateType: def native_value(self) -> StateType:
"""Return the value reported by the sensor.""" """Return the value reported by the sensor."""
# Unknown or unsupported data type # Only continue if data type is known
if self._status_range is None or self._status_range.type not in ( if self._type not in (
"Integer", DPType.INTEGER,
"String", DPType.STRING,
"Enum", DPType.ENUM,
"Json", DPType.JSON,
"Raw", DPType.RAW,
): ):
return None return None
@ -871,13 +871,13 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
return None return None
# Get subkey value from Json string. # Get subkey value from Json string.
if self._status_range.type == "Json": if self._type is DPType.JSON:
if self.entity_description.subkey is None: if self.entity_description.subkey is None:
return None return None
values = ElectricityTypeData.from_json(value) values = ElectricityTypeData.from_json(value)
return getattr(values, self.entity_description.subkey) return getattr(values, self.entity_description.subkey)
if self._status_range.type == "Raw": if self._type is DPType.RAW:
if self.entity_description.subkey is None: if self.entity_description.subkey is None:
return None return None
values = ElectricityTypeData.from_raw(value) values = ElectricityTypeData.from_raw(value)

View file

@ -30,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeAssistantTuyaData from . import HomeAssistantTuyaData
from .base import EnumTypeData, IntegerTypeData, TuyaEntity from .base import EnumTypeData, IntegerTypeData, TuyaEntity
from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType
TUYA_STATUS_TO_HA = { TUYA_STATUS_TO_HA = {
"charge_done": STATE_DOCKED, "charge_done": STATE_DOCKED,
@ -81,48 +81,50 @@ async def async_setup_entry(
class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
"""Tuya Vacuum Device.""" """Tuya Vacuum Device."""
_fan_speed_type: EnumTypeData | None = None _fan_speed: EnumTypeData | None = None
_battery_level_type: IntegerTypeData | None = None _battery_level: IntegerTypeData | None = None
_supported_features = 0 _supported_features = 0
def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None: def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None:
"""Init Tuya vacuum.""" """Init Tuya vacuum."""
super().__init__(device, device_manager) super().__init__(device, device_manager)
if DPCode.PAUSE in self.device.status: if self.find_dpcode(DPCode.PAUSE, prefer_function=True):
self._supported_features |= SUPPORT_PAUSE self._supported_features |= SUPPORT_PAUSE
if DPCode.SWITCH_CHARGE in self.device.status: if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True):
self._supported_features |= SUPPORT_RETURN_HOME self._supported_features |= SUPPORT_RETURN_HOME
if DPCode.SEEK in self.device.status: if self.find_dpcode(DPCode.SEEK, prefer_function=True):
self._supported_features |= SUPPORT_LOCATE self._supported_features |= SUPPORT_LOCATE
if DPCode.STATUS in self.device.status: if self.find_dpcode(DPCode.STATUS, prefer_function=True):
self._supported_features |= SUPPORT_STATE | SUPPORT_STATUS self._supported_features |= SUPPORT_STATE | SUPPORT_STATUS
if DPCode.POWER in self.device.status: if self.find_dpcode(DPCode.POWER, prefer_function=True):
self._supported_features |= SUPPORT_TURN_ON | SUPPORT_TURN_OFF self._supported_features |= SUPPORT_TURN_ON | SUPPORT_TURN_OFF
if DPCode.POWER_GO in self.device.status: if self.find_dpcode(DPCode.POWER_GO, prefer_function=True):
self._supported_features |= SUPPORT_STOP | SUPPORT_START self._supported_features |= SUPPORT_STOP | SUPPORT_START
if function := device.function.get(DPCode.SUCTION): if enum_type := self.find_dpcode(
DPCode.SUCTION, dptype=DPType.ENUM, prefer_function=True
):
self._supported_features |= SUPPORT_FAN_SPEED self._supported_features |= SUPPORT_FAN_SPEED
self._fan_speed_type = EnumTypeData.from_json(function.values) self._fan_speed = enum_type
if status_range := device.status_range.get(DPCode.ELECTRICITY_LEFT): if int_type := self.find_dpcode(DPCode.SUCTION, dptype=DPType.INTEGER):
self._supported_features |= SUPPORT_BATTERY self._supported_features |= SUPPORT_BATTERY
self._battery_level_type = IntegerTypeData.from_json(status_range.values) self._battery_level = int_type
@property @property
def battery_level(self) -> int | None: def battery_level(self) -> int | None:
"""Return Tuya device state.""" """Return Tuya device state."""
if self._battery_level_type is None or not ( if self._battery_level is None or not (
status := self.device.status.get(DPCode.ELECTRICITY_LEFT) status := self.device.status.get(DPCode.ELECTRICITY_LEFT)
): ):
return None return None
return round(self._battery_level_type.scale_value(status)) return round(self._battery_level.scale_value(status))
@property @property
def fan_speed(self) -> str | None: def fan_speed(self) -> str | None:
@ -132,9 +134,9 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
@property @property
def fan_speed_list(self) -> list[str]: def fan_speed_list(self) -> list[str]:
"""Get the list of available fan speed steps of the vacuum cleaner.""" """Get the list of available fan speed steps of the vacuum cleaner."""
if self._fan_speed_type is None: if self._fan_speed is None:
return [] return []
return self._fan_speed_type.range return self._fan_speed.range
@property @property
def state(self) -> str | None: def state(self) -> str | None: