2021-09-30 10:02:56 +00:00
|
|
|
"""Tuya Home Assistant Base Device Model."""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
2022-01-06 11:01:16 +00:00
|
|
|
import base64
|
2021-10-13 18:29:11 +00:00
|
|
|
from dataclasses import dataclass
|
|
|
|
import json
|
2022-01-06 11:01:16 +00:00
|
|
|
import struct
|
2022-01-23 08:01:10 +00:00
|
|
|
from typing import Any, Literal, overload
|
2021-09-30 10:02:56 +00:00
|
|
|
|
|
|
|
from tuya_iot import TuyaDevice, TuyaDeviceManager
|
|
|
|
|
|
|
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
2021-10-12 10:25:03 +00:00
|
|
|
from homeassistant.helpers.entity import DeviceInfo, Entity
|
2021-09-30 10:02:56 +00:00
|
|
|
|
2022-01-23 08:01:10 +00:00
|
|
|
from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType
|
2021-10-22 18:28:16 +00:00
|
|
|
from .util import remap_value
|
2021-09-30 10:02:56 +00:00
|
|
|
|
2021-10-13 18:29:11 +00:00
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class IntegerTypeData:
|
|
|
|
"""Integer Type Data."""
|
|
|
|
|
2022-01-23 08:01:10 +00:00
|
|
|
dpcode: DPCode
|
2021-10-13 18:29:11 +00:00
|
|
|
min: int
|
|
|
|
max: int
|
|
|
|
scale: float
|
|
|
|
step: float
|
2021-10-18 18:35:01 +00:00
|
|
|
unit: str | None = None
|
2021-11-23 22:33:36 +00:00
|
|
|
type: str | None = None
|
2021-10-13 18:29:11 +00:00
|
|
|
|
2021-10-15 08:33:20 +00:00
|
|
|
@property
|
|
|
|
def max_scaled(self) -> float:
|
|
|
|
"""Return the max scaled."""
|
|
|
|
return self.scale_value(self.max)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def min_scaled(self) -> float:
|
|
|
|
"""Return the min scaled."""
|
|
|
|
return self.scale_value(self.min)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def step_scaled(self) -> float:
|
|
|
|
"""Return the step scaled."""
|
2022-02-16 13:33:08 +00:00
|
|
|
return self.step / (10**self.scale)
|
2021-10-15 08:33:20 +00:00
|
|
|
|
|
|
|
def scale_value(self, value: float | int) -> float:
|
|
|
|
"""Scale a value."""
|
2022-04-26 23:49:11 +00:00
|
|
|
return value / (10**self.scale)
|
2021-10-15 08:33:20 +00:00
|
|
|
|
2021-11-18 15:53:34 +00:00
|
|
|
def scale_value_back(self, value: float | int) -> int:
|
|
|
|
"""Return raw value for scaled."""
|
2022-04-26 23:49:11 +00:00
|
|
|
return int(value * (10**self.scale))
|
2021-11-18 15:53:34 +00:00
|
|
|
|
2021-10-18 18:35:01 +00:00
|
|
|
def remap_value_to(
|
|
|
|
self,
|
|
|
|
value: float,
|
|
|
|
to_min: float | int = 0,
|
|
|
|
to_max: float | int = 255,
|
|
|
|
reverse: bool = False,
|
|
|
|
) -> float:
|
|
|
|
"""Remap a value from this range to a new range."""
|
2021-10-22 18:28:16 +00:00
|
|
|
return remap_value(value, self.min, self.max, to_min, to_max, reverse)
|
2021-10-18 18:35:01 +00:00
|
|
|
|
|
|
|
def remap_value_from(
|
|
|
|
self,
|
|
|
|
value: float,
|
|
|
|
from_min: float | int = 0,
|
|
|
|
from_max: float | int = 255,
|
|
|
|
reverse: bool = False,
|
|
|
|
) -> float:
|
|
|
|
"""Remap a value from its current range to this range."""
|
2021-10-22 18:28:16 +00:00
|
|
|
return remap_value(value, from_min, from_max, self.min, self.max, reverse)
|
2021-10-18 18:35:01 +00:00
|
|
|
|
2021-10-13 20:58:10 +00:00
|
|
|
@classmethod
|
2022-02-03 20:46:05 +00:00
|
|
|
def from_json(cls, dpcode: DPCode, data: str) -> IntegerTypeData | None:
|
2021-10-13 18:29:11 +00:00
|
|
|
"""Load JSON string and return a IntegerTypeData object."""
|
2022-02-03 20:46:05 +00:00
|
|
|
if not (parsed := json.loads(data)):
|
|
|
|
return None
|
|
|
|
|
2022-01-26 22:05:01 +00:00
|
|
|
return cls(
|
|
|
|
dpcode,
|
|
|
|
min=int(parsed["min"]),
|
|
|
|
max=int(parsed["max"]),
|
|
|
|
scale=float(parsed["scale"]),
|
2022-02-16 13:33:08 +00:00
|
|
|
step=max(float(parsed["step"]), 1),
|
2022-01-26 22:05:01 +00:00
|
|
|
unit=parsed.get("unit"),
|
|
|
|
type=parsed.get("type"),
|
|
|
|
)
|
2021-10-13 18:29:11 +00:00
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class EnumTypeData:
|
|
|
|
"""Enum Type Data."""
|
|
|
|
|
2022-01-23 08:01:10 +00:00
|
|
|
dpcode: DPCode
|
2021-10-13 18:29:11 +00:00
|
|
|
range: list[str]
|
|
|
|
|
2021-10-13 20:58:10 +00:00
|
|
|
@classmethod
|
2022-02-03 20:46:05 +00:00
|
|
|
def from_json(cls, dpcode: DPCode, data: str) -> EnumTypeData | None:
|
2021-10-13 18:29:11 +00:00
|
|
|
"""Load JSON string and return a EnumTypeData object."""
|
2022-02-03 20:46:05 +00:00
|
|
|
if not (parsed := json.loads(data)):
|
|
|
|
return None
|
|
|
|
return cls(dpcode, **parsed)
|
2021-10-13 18:29:11 +00:00
|
|
|
|
2021-09-30 10:02:56 +00:00
|
|
|
|
2021-12-01 12:17:02 +00:00
|
|
|
@dataclass
|
|
|
|
class ElectricityTypeData:
|
|
|
|
"""Electricity Type Data."""
|
|
|
|
|
|
|
|
electriccurrent: str | None = None
|
|
|
|
power: str | None = None
|
|
|
|
voltage: str | None = None
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def from_json(cls, data: str) -> ElectricityTypeData:
|
|
|
|
"""Load JSON string and return a ElectricityTypeData object."""
|
|
|
|
return cls(**json.loads(data.lower()))
|
|
|
|
|
2022-01-06 11:01:16 +00:00
|
|
|
@classmethod
|
|
|
|
def from_raw(cls, data: str) -> ElectricityTypeData:
|
|
|
|
"""Decode base64 string and return a ElectricityTypeData object."""
|
|
|
|
raw = base64.b64decode(data)
|
|
|
|
voltage = struct.unpack(">H", raw[0:2])[0] / 10.0
|
|
|
|
electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0
|
|
|
|
power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0
|
|
|
|
return cls(
|
|
|
|
electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage)
|
|
|
|
)
|
|
|
|
|
2021-12-01 12:17:02 +00:00
|
|
|
|
2021-10-13 21:30:25 +00:00
|
|
|
class TuyaEntity(Entity):
|
2021-09-30 10:02:56 +00:00
|
|
|
"""Tuya base device."""
|
|
|
|
|
2021-10-12 03:37:18 +00:00
|
|
|
_attr_should_poll = False
|
|
|
|
|
2021-09-30 10:02:56 +00:00
|
|
|
def __init__(self, device: TuyaDevice, device_manager: TuyaDeviceManager) -> None:
|
|
|
|
"""Init TuyaHaEntity."""
|
2021-10-12 03:37:18 +00:00
|
|
|
self._attr_unique_id = f"tuya.{device.id}"
|
2021-10-15 09:33:30 +00:00
|
|
|
self.device = device
|
|
|
|
self.device_manager = device_manager
|
2021-09-30 10:02:56 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def name(self) -> str | None:
|
|
|
|
"""Return Tuya device name."""
|
2021-10-15 08:07:25 +00:00
|
|
|
if (
|
|
|
|
hasattr(self, "entity_description")
|
|
|
|
and self.entity_description.name is not None
|
|
|
|
):
|
2021-10-15 09:33:30 +00:00
|
|
|
return f"{self.device.name} {self.entity_description.name}"
|
|
|
|
return self.device.name
|
2021-09-30 10:02:56 +00:00
|
|
|
|
|
|
|
@property
|
2021-10-12 10:25:03 +00:00
|
|
|
def device_info(self) -> DeviceInfo:
|
2021-09-30 10:02:56 +00:00
|
|
|
"""Return a device description for device registry."""
|
2021-10-12 10:25:03 +00:00
|
|
|
return DeviceInfo(
|
2021-10-15 09:33:30 +00:00
|
|
|
identifiers={(DOMAIN, self.device.id)},
|
2021-10-12 10:25:03 +00:00
|
|
|
manufacturer="Tuya",
|
2021-10-15 09:33:30 +00:00
|
|
|
name=self.device.name,
|
2021-10-22 17:39:31 +00:00
|
|
|
model=f"{self.device.product_name} ({self.device.product_id})",
|
2021-10-12 10:25:03 +00:00
|
|
|
)
|
2021-09-30 10:02:56 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def available(self) -> bool:
|
|
|
|
"""Return if the device is available."""
|
2021-10-15 09:33:30 +00:00
|
|
|
return self.device.online
|
2021-09-30 10:02:56 +00:00
|
|
|
|
2022-01-23 08:01:10 +00:00
|
|
|
@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
|
|
|
|
):
|
2022-02-03 20:46:05 +00:00
|
|
|
if not (
|
|
|
|
enum_type := EnumTypeData.from_json(
|
|
|
|
dpcode, getattr(self.device, key)[dpcode].values
|
|
|
|
)
|
|
|
|
):
|
|
|
|
continue
|
|
|
|
return enum_type
|
2022-01-23 08:01:10 +00:00
|
|
|
|
|
|
|
if (
|
|
|
|
dptype == DPType.INTEGER
|
|
|
|
and getattr(self.device, key)[dpcode].type == DPType.INTEGER
|
|
|
|
):
|
2022-02-03 20:46:05 +00:00
|
|
|
if not (
|
|
|
|
integer_type := IntegerTypeData.from_json(
|
|
|
|
dpcode, getattr(self.device, key)[dpcode].values
|
|
|
|
)
|
|
|
|
):
|
|
|
|
continue
|
|
|
|
return integer_type
|
2022-01-23 08:01:10 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
|
2021-10-12 10:25:03 +00:00
|
|
|
async def async_added_to_hass(self) -> None:
|
2021-09-30 10:02:56 +00:00
|
|
|
"""Call when entity is added to hass."""
|
|
|
|
self.async_on_remove(
|
|
|
|
async_dispatcher_connect(
|
|
|
|
self.hass,
|
2021-10-15 09:33:30 +00:00
|
|
|
f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{self.device.id}",
|
2021-09-30 10:02:56 +00:00
|
|
|
self.async_write_ha_state,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
def _send_command(self, commands: list[dict[str, Any]]) -> None:
|
|
|
|
"""Send command to the device."""
|
2021-12-29 14:12:27 +00:00
|
|
|
LOGGER.debug("Sending commands for device %s: %s", self.device.id, commands)
|
2021-10-15 09:33:30 +00:00
|
|
|
self.device_manager.send_commands(self.device.id, commands)
|