404 lines
14 KiB
Python
404 lines
14 KiB
Python
"""Code shared between all platforms."""
|
|
|
|
import logging
|
|
from typing import Any, Coroutine, Callable
|
|
|
|
from homeassistant.core import HomeAssistant, State
|
|
from homeassistant.config_entries import ConfigEntry
|
|
|
|
from homeassistant.const import (
|
|
CONF_DEVICES,
|
|
CONF_DEVICE_CLASS,
|
|
CONF_ENTITIES,
|
|
CONF_ENTITY_CATEGORY,
|
|
CONF_FRIENDLY_NAME,
|
|
CONF_HOST,
|
|
CONF_ICON,
|
|
CONF_ID,
|
|
CONF_PLATFORM,
|
|
EntityCategory,
|
|
STATE_UNAVAILABLE,
|
|
STATE_UNKNOWN,
|
|
ATTR_VIA_DEVICE,
|
|
)
|
|
from homeassistant.helpers.device_registry import DeviceInfo
|
|
from homeassistant.helpers.dispatcher import (
|
|
async_dispatcher_connect,
|
|
async_dispatcher_send,
|
|
)
|
|
|
|
from homeassistant.helpers.restore_state import RestoreEntity
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
|
|
from .core import pytuya
|
|
from .coordinator import HassLocalTuyaData, TuyaDevice
|
|
from .const import (
|
|
ATTR_STATE,
|
|
CONF_DEFAULT_VALUE,
|
|
CONF_ID,
|
|
CONF_NODE_ID,
|
|
CONF_PASSIVE_ENTITY,
|
|
CONF_RESTORE_ON_RECONNECT,
|
|
CONF_SCALING,
|
|
DOMAIN,
|
|
RESTORE_STATES,
|
|
DeviceConfig,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
async def async_setup_entry(
|
|
domain: str,
|
|
entity_class: Any,
|
|
flow_schema: Callable,
|
|
hass: HomeAssistant,
|
|
config_entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
async_setup_services: Coroutine[HomeAssistant, list, None] = None,
|
|
):
|
|
"""Set up a Tuya platform based on a config entry.
|
|
|
|
This is a generic method and each platform should lock domain and
|
|
entity_class with functools.partial.
|
|
"""
|
|
entities = []
|
|
hass_entry_data: HassLocalTuyaData = hass.data[DOMAIN][config_entry.entry_id]
|
|
|
|
for dev_id in config_entry.data[CONF_DEVICES]:
|
|
dev_entry: dict = config_entry.data[CONF_DEVICES][dev_id]
|
|
|
|
host = dev_entry.get(CONF_HOST)
|
|
node_id = dev_entry.get(CONF_NODE_ID)
|
|
device_key = f"{host}_{node_id}" if node_id else host
|
|
|
|
if device_key not in hass_entry_data.devices:
|
|
continue
|
|
|
|
entities_to_setup = [
|
|
entity
|
|
for entity in dev_entry[CONF_ENTITIES]
|
|
if entity[CONF_PLATFORM] == domain
|
|
]
|
|
|
|
if entities_to_setup:
|
|
device: TuyaDevice = hass_entry_data.devices[device_key]
|
|
dps_config_fields = list(get_dps_for_platform(flow_schema))
|
|
|
|
for entity_config in entities_to_setup:
|
|
# Add DPS used by this platform to the request list
|
|
for dp_conf in dps_config_fields:
|
|
if dp_conf in entity_config:
|
|
device.dps_to_request[entity_config[dp_conf]] = None
|
|
|
|
entities.append(
|
|
entity_class(
|
|
device,
|
|
dev_entry,
|
|
entity_config[CONF_ID],
|
|
# we need add_entites_callback in-case we want to add sub-entites, such as electric sensor "phase_a"
|
|
add_entites_callback=async_add_entities,
|
|
)
|
|
)
|
|
# Once the entities have been created, add to the TuyaDevice instance
|
|
if entities:
|
|
device.add_entities(entities)
|
|
async_add_entities(entities)
|
|
|
|
if async_setup_services:
|
|
await async_setup_services(hass, entities)
|
|
|
|
|
|
def get_dps_for_platform(flow_schema):
|
|
"""Return config keys for all platform keys that depends on a datapoint."""
|
|
for key, value in flow_schema(None).items():
|
|
if hasattr(value, "container") and value.container is None:
|
|
yield key.schema
|
|
|
|
|
|
def get_entity_config(config_entry, dp_id) -> dict:
|
|
"""Return entity config for a given DPS id."""
|
|
for entity in config_entry[CONF_ENTITIES]:
|
|
if entity[CONF_ID] == dp_id:
|
|
return entity
|
|
raise Exception(f"missing entity config for id {dp_id}")
|
|
|
|
|
|
class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):
|
|
"""Representation of a Tuya entity."""
|
|
|
|
_attr_device_class = None
|
|
_attr_has_entity_name = True
|
|
_attr_should_poll = False
|
|
|
|
def __init__(
|
|
self, device: TuyaDevice, device_config: dict, dp_id: str, logger, **kwargs
|
|
):
|
|
"""Initialize the Tuya entity."""
|
|
super().__init__()
|
|
self._device = device
|
|
self._device_config = DeviceConfig(device_config)
|
|
self._config = get_entity_config(device_config, dp_id)
|
|
self._dp_id = dp_id
|
|
self._status = {}
|
|
self._state = None
|
|
self._last_state = None
|
|
self._stored_states: State | None = None
|
|
self.hass = device.hass
|
|
self.componet_add_entities: AddEntitiesCallback = kwargs.get(
|
|
"add_entites_callback"
|
|
)
|
|
self._loaded = False
|
|
|
|
# Default value is available to be provided by Platform entities if required
|
|
self._default_value = self._config.get(CONF_DEFAULT_VALUE)
|
|
|
|
""" Restore on connect setting is available to be provided by Platform entities
|
|
if required"""
|
|
dev = self._device_config
|
|
self.set_logger(logger, dev.id, dev.enable_debug, dev.name)
|
|
self.debug(f"Initialized {self._config.get(CONF_PLATFORM)} [{self.name}]")
|
|
|
|
async def async_added_to_hass(self):
|
|
"""Subscribe localtuya events."""
|
|
await super().async_added_to_hass()
|
|
|
|
self.debug(f"Adding {self.entity_id} with configuration: {self._config}")
|
|
|
|
stored_data = await self.async_get_last_state()
|
|
if stored_data:
|
|
self._stored_states = stored_data
|
|
self.status_restored(stored_data)
|
|
|
|
def _update_handler(status: dict | None):
|
|
"""Update entity state when status was updated."""
|
|
last_status = self._status.copy()
|
|
|
|
self._status = {} if status is None else {**self._status, **status}
|
|
|
|
if not self._loaded:
|
|
self._loaded = True
|
|
self.connection_made()
|
|
|
|
if status != last_status:
|
|
if status:
|
|
self.status_updated()
|
|
|
|
self.schedule_update_ha_state()
|
|
|
|
signal = f"localtuya_{self._device_config.id}"
|
|
|
|
self.async_on_remove(
|
|
async_dispatcher_connect(self.hass, signal, _update_handler)
|
|
)
|
|
|
|
signal = f"localtuya_entity_{self._device_config.id}"
|
|
async_dispatcher_send(self.hass, signal, self.entity_id)
|
|
|
|
@property
|
|
def extra_state_attributes(self):
|
|
"""Return entity specific state attributes to be saved.
|
|
|
|
These attributes are then available for restore when the
|
|
entity is restored at startup.
|
|
"""
|
|
attributes = {}
|
|
if self._state is not None:
|
|
attributes[ATTR_STATE] = self._state
|
|
elif self._last_state is not None:
|
|
attributes[ATTR_STATE] = self._last_state
|
|
|
|
self.debug(f"Entity {self.name} - Additional attributes: {attributes}")
|
|
return attributes
|
|
|
|
@property
|
|
def device_info(self):
|
|
"""Return device information for the device registry."""
|
|
device_config = self._device_config
|
|
device_info = DeviceInfo(
|
|
# Serial numbers are unique identifiers within a specific domain
|
|
identifiers={(DOMAIN, f"local_{device_config.id}")},
|
|
name=device_config.name,
|
|
manufacturer="Tuya",
|
|
model=f"{device_config.model} ({device_config.id})",
|
|
sw_version=device_config.protocol_version,
|
|
)
|
|
if self._device.is_subdevice and self._device.id != self._device.gateway.id:
|
|
device_info[ATTR_VIA_DEVICE] = (DOMAIN, f"local_{self._device.gateway.id}")
|
|
return device_info
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Get name of Tuya entity."""
|
|
return getattr(self, "_attr_name", self._config.get(CONF_FRIENDLY_NAME))
|
|
|
|
@property
|
|
def icon(self) -> str | None:
|
|
"""Icon of the entity."""
|
|
return self._config.get(CONF_ICON, None)
|
|
|
|
@property
|
|
def unique_id(self) -> str:
|
|
"""Return unique device identifier."""
|
|
if getattr(self, "_attr_unique_id") is not None:
|
|
return self._attr_unique_id
|
|
|
|
return f"local_{self._device_config.id}_{self._dp_id}"
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
"""Return if device is available or not."""
|
|
return (len(self._status) > 0) or self._device.connected
|
|
|
|
@property
|
|
def entity_category(self) -> str:
|
|
"""Return the category of the entity."""
|
|
if category := self._config.get(CONF_ENTITY_CATEGORY):
|
|
return EntityCategory(category) if category != "None" else None
|
|
else:
|
|
# Set Default values for unconfigured devices.
|
|
if platform := self._config.get(CONF_PLATFORM):
|
|
# Call default_category from config_flow to set default values!
|
|
# This will be removed after a while, this is only made to convert who came from main integration.
|
|
# new users will be forced to choose category from config_flow.
|
|
from .config_flow import default_category
|
|
|
|
return default_category(platform)
|
|
return None
|
|
|
|
@property
|
|
def device_class(self):
|
|
"""Return the class of this device."""
|
|
return getattr(self, "_attr_device_class", self._config.get(CONF_DEVICE_CLASS))
|
|
|
|
def has_config(self, attr) -> bool:
|
|
"""Return if a config parameter has a valid value."""
|
|
value = self._config.get(attr, "-1")
|
|
return value is not None and value != "-1"
|
|
|
|
def dp_value(self, key, default=None) -> Any | None:
|
|
"""Return cached value for DPS index or Entity Config Key. else default None"""
|
|
requested_dp = str(key)
|
|
# If requested_dp in DP ID, get cached value.
|
|
if (value := self._status.get(requested_dp)) or value is not None:
|
|
return value
|
|
|
|
# If requested_dp is an config key get config dp then get cached value.
|
|
if (conf_key := self._config.get(requested_dp)) or conf_key is not None:
|
|
if (value := self._status.get(conf_key)) or value is not None:
|
|
return value
|
|
|
|
if value is None:
|
|
value = default
|
|
# self.debug(f"{self.name}: is requesting unknown DP Value {key}", force=True)
|
|
|
|
return value
|
|
|
|
def status_updated(self) -> None:
|
|
"""Device status was updated.
|
|
|
|
Override in subclasses and update entity specific state.
|
|
"""
|
|
state = self.dp_value(self._dp_id)
|
|
self._state = state
|
|
|
|
# Keep record in last_state as long as not during connection/re-connection,
|
|
# as last state will be used to restore the previous state
|
|
if (state is not None) and (not self._device.is_connecting):
|
|
self._last_state = state
|
|
|
|
def status_restored(self, stored_state: State) -> None:
|
|
"""Device status was restored.
|
|
|
|
Override in subclasses and update entity specific state.
|
|
"""
|
|
raw_state = stored_state.attributes.get(ATTR_STATE)
|
|
if raw_state is not None:
|
|
self._last_state = raw_state
|
|
self.debug(
|
|
f"Restoring state for entity: {self.name} - state: {str(self._last_state)}"
|
|
)
|
|
|
|
def connection_made(self):
|
|
"""The connection has made with the device and status retrieved. configure entity based on it.
|
|
|
|
Override in subclasses and update entity initialization based on detected DPS.
|
|
"""
|
|
stored_data = self._stored_states
|
|
if self._status == RESTORE_STATES and stored_data:
|
|
self._status.pop("0", True)
|
|
if self._dp_id in self._status:
|
|
return
|
|
if stored_data.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
|
self.debug(f"{self.name}: Restore state: {stored_data.state}")
|
|
self._status[self._dp_id] = stored_data.state
|
|
|
|
def default_value(self):
|
|
"""Return default value of this entity.
|
|
|
|
Override in subclasses to specify the default value for the entity.
|
|
"""
|
|
# Check if default value has been set - if not, default to the entity defaults.
|
|
if self._default_value is None:
|
|
self._default_value = self.entity_default_value()
|
|
|
|
return self._default_value
|
|
|
|
def entity_default_value(self): # pylint: disable=no-self-use
|
|
"""Return default value of the entity type.
|
|
|
|
Override in subclasses to specify the default value for the entity.
|
|
"""
|
|
return 0
|
|
|
|
def scale(self, value):
|
|
"""Return the scaled factor of the value, else same value."""
|
|
scale_factor = self._config.get(CONF_SCALING)
|
|
if scale_factor is not None and isinstance(value, (int, float)):
|
|
value = round(value * scale_factor, 2)
|
|
|
|
return value
|
|
|
|
async def restore_state_when_connected(self) -> None:
|
|
"""Restore if restore_on_reconnect is set, or if no status has been yet found.
|
|
|
|
Which indicates a DPS that needs to be set before it starts returning
|
|
status.
|
|
"""
|
|
restore_on_reconnect = self._config.get(CONF_RESTORE_ON_RECONNECT, False)
|
|
passive_entity = self._config.get(CONF_PASSIVE_ENTITY, False)
|
|
dp_id = str(self._dp_id)
|
|
|
|
if not restore_on_reconnect and (dp_id in self._status or not passive_entity):
|
|
self.debug(
|
|
f"Entity {self.name} (DP {self._dp_id}) - Not restoring as restore on reconnect is "
|
|
+ "disabled for this entity and the entity has an initial status "
|
|
+ "or it is not a passive entity"
|
|
)
|
|
return
|
|
|
|
self.debug(f"Attempting to restore state for entity: {self.name}")
|
|
# Attempt to restore the current state - in case reset.
|
|
restore_state = self._state
|
|
|
|
# If no state stored in the entity currently, go from last saved state
|
|
if (restore_state == STATE_UNKNOWN) | (restore_state is None):
|
|
self.debug("No current state for entity")
|
|
restore_state = self._last_state
|
|
|
|
# If no current or saved state, then use the default value
|
|
if restore_state is None:
|
|
if passive_entity:
|
|
self.debug("No last restored state - using default")
|
|
restore_state = self.default_value()
|
|
else:
|
|
self.debug("Not a passive entity and no state found - aborting restore")
|
|
return
|
|
|
|
self.debug(
|
|
f"Entity {self.name} (DP {self._dp_id}) - Restoring state: {str(restore_state)}"
|
|
)
|
|
|
|
# Manually initialise
|
|
await self._device.set_dp(restore_state, self._dp_id)
|