Files
rikku-home-assistant/custom_components/localtuya/__init__.py
T
2025-04-21 21:03:30 -04:00

539 lines
20 KiB
Python

"""The LocalTuya integration."""
import asyncio
from dataclasses import dataclass
import logging
import time
from datetime import timedelta
from typing import Any, NamedTuple
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.device_registry as dr
import homeassistant.helpers.entity_registry as er
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_DEVICES,
CONF_DEVICE_ID,
CONF_ENTITIES,
CONF_HOST,
CONF_ID,
CONF_PLATFORM,
CONF_REGION,
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
)
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.event import async_track_time_interval
from .coordinator import TuyaDevice, HassLocalTuyaData, TuyaCloudApi
from .config_flow import ENTRIES_VERSION
from .const import (
ATTR_UPDATED_AT,
CONF_GATEWAY_ID,
CONF_NODE_ID,
CONF_NO_CLOUD,
CONF_PRODUCT_KEY,
CONF_USER_ID,
DATA_DISCOVERY,
DOMAIN,
PLATFORMS,
)
from .discovery import TuyaDiscovery
_LOGGER = logging.getLogger(__name__)
CONF_DP = "dp"
CONF_VALUE = "value"
SERVICE_SET_DP = "set_dp"
SERVICE_SET_DP_SCHEMA = vol.Schema(
{
vol.Required(CONF_DEVICE_ID): cv.string,
vol.Optional(CONF_DP): int,
vol.Required(CONF_VALUE): object,
}
)
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the LocalTuya integration component."""
hass.data.setdefault(DOMAIN, {})
current_entries = hass.config_entries.async_entries(DOMAIN)
device_cache = {}
async def _handle_reload(service: ServiceCall):
"""Handle reload service call."""
_LOGGER.info("Service %s.reload called: reloading integration", DOMAIN)
current_entries = hass.config_entries.async_entries(DOMAIN)
reload_tasks = [
hass.config_entries.async_reload(entry.entry_id)
for entry in current_entries
]
await asyncio.gather(*reload_tasks)
async def _handle_set_dp(event: ServiceCall):
"""Handle set_dp service call."""
dev_id = event.data[CONF_DEVICE_ID]
entry: ConfigEntry = async_config_entry_by_device_id(hass, dev_id)
if not entry or not entry.entry_id:
raise HomeAssistantError("unknown device id")
host = entry.data[CONF_DEVICES][dev_id].get(CONF_HOST)
if node_id := entry.data[CONF_DEVICES][dev_id].get(CONF_NODE_ID):
host = f"{host}_{node_id}"
device: TuyaDevice = hass.data[DOMAIN][entry.entry_id].devices[host]
if not device.connected:
raise HomeAssistantError("not connected to device")
value = event.data[CONF_VALUE]
if isinstance(value, dict):
await device.set_dps(value)
else:
await device.set_dp(value, event.data[CONF_DP])
def _device_discovered(device: dict):
"""Update address of device if it has changed."""
device_ip = device["ip"]
device_id = device["gwId"]
product_key = device["productKey"]
# If device is not in cache, check if a config entry exists
entry: ConfigEntry = async_config_entry_by_device_id(hass, device_id)
if entry is None:
return
hass_data: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id]
if device_id not in device_cache or device_id not in device_cache.get(
device_id, {}
):
if entry and device_id in entry.data[CONF_DEVICES]:
# Save address from config entry in cache to trigger
# potential update below
host_ip = entry.data[CONF_DEVICES][device_id][CONF_HOST]
device_cache[device_id] = {device_id: host_ip}
for subdev_id, dev_config in entry.data[CONF_DEVICES].items():
if dev_config.get(CONF_NODE_ID):
if gateway_id := dev_config.get(CONF_GATEWAY_ID):
if entry and device_id == gateway_id:
device_cache[device_id] = device_cache.get(device_id, {})
device_cache[device_id].update(
{subdev_id: dev_config.get(CONF_HOST)}
)
if device_id not in device_cache:
return
if not entry.state == ConfigEntryState.LOADED:
return
if device := hass_data.devices.get(device_ip):
...
# hass.create_task(hass_data.cloud_data.async_get_devices_list())
new_data = entry.data.copy()
updated = False
for dev_id, host in device_cache[device_id].items():
if dev_id not in entry.data[CONF_DEVICES]:
continue
dev_entry = entry.data[CONF_DEVICES][dev_id]
if host != device_ip:
updated = True
new_data[CONF_DEVICES][dev_id][CONF_HOST] = device_ip
device_cache[device_id][dev_id] = device_ip
if (p_key := dev_entry.get(CONF_PRODUCT_KEY)) and p_key != product_key:
updated = True
new_data[CONF_DEVICES][dev_id][CONF_PRODUCT_KEY] = product_key
# Update settings if something changed, otherwise try to connect. Updating
# settings triggers a reload of the config entry, which tears down the device
# so no need to connect in that case.
if updated:
_LOGGER.debug(
"Updating keys for device %s: %s %s", device_id, device_ip, product_key
)
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
hass.config_entries.async_update_entry(entry, data=new_data)
def _shutdown(event):
"""Clean up resources when shutting down."""
discovery.close()
hass.services.async_register(DOMAIN, SERVICE_RELOAD, _handle_reload)
hass.services.async_register(
DOMAIN, SERVICE_SET_DP, _handle_set_dp, schema=SERVICE_SET_DP_SCHEMA
)
discovery = TuyaDiscovery(_device_discovered)
try:
await discovery.start()
hass.data[DOMAIN][DATA_DISCOVERY] = discovery
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("failed to set up discovery")
return True
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Migrate old entries merging all of them in one."""
new_version = ENTRIES_VERSION
stored_entries = hass.config_entries.async_entries(DOMAIN)
if config_entry.version == 1:
# This an old version of original integration no need to put it here.
pass
# Update to version 3
if config_entry.version == 2:
# Switch config flow to selectors convert DP IDs from int to str require HA 2022.4.
_LOGGER.debug("Migrating config entry from version %s", config_entry.version)
new_data = config_entry.data.copy()
for device in new_data[CONF_DEVICES]:
i = 0
for _ent in new_data[CONF_DEVICES][device][CONF_ENTITIES]:
ent_items = {}
for k, v in _ent.items():
ent_items[k] = str(v) if type(v) is int else v
new_data[CONF_DEVICES][device][CONF_ENTITIES][i].update(ent_items)
i = i + 1
hass.config_entries.async_update_entry(config_entry, data=new_data, version=3)
# Update to version 4
if config_entry.version <= 3:
# Convert values and friendly name values to dict.
from .const import (
Platform,
CONF_OPTIONS,
CONF_HVAC_MODE_SET,
CONF_HVAC_ACTION_SET,
CONF_PRESET_SET,
CONF_SCENE_VALUES,
# Deprecated
CONF_SCENE_VALUES_FRIENDLY,
CONF_OPTIONS_FRIENDLY,
CONF_HVAC_ADD_OFF,
)
from .climate import (
RENAME_HVAC_MODE_SETS,
RENAME_ACTION_SETS,
RENAME_PRESET_SETS,
HVAC_OFF,
)
def convert_str_to_dict(list1: str, list2: str = ""):
to_dict = {}
if not isinstance(list1, str):
return list1
list1, list2 = list1.replace(";", ","), list2.replace(";", ",")
v, v_fn = list1.split(","), list2.split(",")
for k in range(len(v)):
to_dict[v[k]] = (
v_fn[k] if k < len(v_fn) and v_fn[k] else v[k].capitalize()
)
return to_dict
new_data = config_entry.data.copy()
for device in new_data[CONF_DEVICES]:
current_entity = 0
for entity in new_data[CONF_DEVICES][device][CONF_ENTITIES]:
new_entity_data = {}
if entity[CONF_PLATFORM] == Platform.SELECT:
# Merge 2 Lists Values and Values friendly names into dict.
v_fn = entity.get(CONF_OPTIONS_FRIENDLY, "")
if v := entity.get(CONF_OPTIONS):
new_entity_data[CONF_OPTIONS] = convert_str_to_dict(v, v_fn)
if entity[CONF_PLATFORM] == Platform.LIGHT:
v_fn = entity.get(CONF_SCENE_VALUES_FRIENDLY, "")
if v := entity.get(CONF_SCENE_VALUES):
new_entity_data[CONF_SCENE_VALUES] = convert_str_to_dict(
v, v_fn
)
if entity[CONF_PLATFORM] == Platform.CLIMATE:
# Merge 2 Lists Values and Values friendly names into dict.
climate_to_dict = {}
for conf, new_values in (
(CONF_HVAC_MODE_SET, RENAME_HVAC_MODE_SETS),
(CONF_HVAC_ACTION_SET, RENAME_ACTION_SETS),
(CONF_PRESET_SET, RENAME_PRESET_SETS),
):
climate_to_dict[conf] = {}
if hvac_set := entity.get(conf, ""):
if entity.get(CONF_HVAC_ADD_OFF, False):
if conf == CONF_HVAC_MODE_SET:
climate_to_dict[conf].update(HVAC_OFF)
if not isinstance(conf, str):
continue
hvac_set = hvac_set.replace("/", ",")
for i in hvac_set.split(","):
for k, v in new_values.items():
if i in k:
new_v = True if i == "True" else i
new_v = False if i == "False" else new_v
climate_to_dict[conf].update({v: new_v})
new_entity_data = climate_to_dict
new_data[CONF_DEVICES][device][CONF_ENTITIES][current_entity].update(
new_entity_data
)
current_entity += 1
hass.config_entries.async_update_entry(config_entry, data=new_data, version=4)
_LOGGER.info(
"Entry %s successfully migrated to version %s.",
config_entry.entry_id,
new_version,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up LocalTuya integration from a config entry."""
if entry.version < ENTRIES_VERSION:
_LOGGER.debug(
"Skipping setup for entry %s since its version (%s) is old",
entry.entry_id,
entry.version,
)
return
region = entry.data[CONF_REGION]
client_id = entry.data[CONF_CLIENT_ID]
secret = entry.data[CONF_CLIENT_SECRET]
user_id = entry.data[CONF_USER_ID]
tuya_api = TuyaCloudApi(region, client_id, secret, user_id)
no_cloud = entry.data.get(CONF_NO_CLOUD, True)
if no_cloud:
_LOGGER.info(f"Cloud API account not configured.")
else:
entry.async_create_background_task(
hass, tuya_api.async_connect(), "localtuya-cloudAPI"
)
hass_localtuya = HassLocalTuyaData(tuya_api, {})
hass.data[DOMAIN][entry.entry_id] = hass_localtuya
def _setup_devices(entry_devices: dict):
"""Setup Localtuya devices object."""
devices = hass_localtuya.devices
connect_to_devices: list[TuyaDevice] = []
# Sort parent devices first then sub-devices.
sorted_devices = dict(
sorted(
entry_devices.items(), key=lambda k: 1 if k[1].get(CONF_NODE_ID) else 0
)
)
for dev_id, config in sorted_devices.items():
if check_if_device_disabled(hass, entry, dev_id):
continue
host = config.get(CONF_HOST)
# Parent Devices.
if not (node_id := config.get(CONF_NODE_ID)):
devices[host] = (dev := TuyaDevice(hass, entry, config))
connect_to_devices.append(dev)
continue
# Sub-Devices
if not (gateway := devices.get(host)):
# Setup sub-device as fake gateway if there is no a gateway exist.
devices[host] = (gateway := TuyaDevice(hass, entry, config, True))
connect_to_devices.append(gateway)
devices[f"{host}_{node_id}"] = (sub_dev := TuyaDevice(hass, entry, config))
sub_dev.gateway = gateway
gateway.sub_devices[node_id] = sub_dev
return connect_to_devices
connect_to_devices = _setup_devices(entry.data[CONF_DEVICES])
await async_remove_orphan_entities(hass, entry)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS.values())
# Note: entry.async_on_unload items are called in LIFO order!
for dev in connect_to_devices:
entry.async_create_task(hass, dev.async_connect())
entry.async_on_unload(dev.close)
entry.async_on_unload(entry.add_update_listener(update_listener))
async def _shutdown(event):
"""Clean up resources when shutting down."""
await asyncio.gather(*[dev.close() for dev in connect_to_devices])
_LOGGER.info(f"{entry.title}: Shutdown completed")
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
)
entry.async_on_unload(_run_async_listen(hass, entry))
_LOGGER.info(f"{entry.title}: Setup completed")
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unloading the Tuya platforms."""
# Unload the platforms.
await hass.config_entries.async_unload_platforms(entry, PLATFORMS.values())
hass.data[DOMAIN].pop(entry.entry_id)
_LOGGER.info("Unload completed")
return True
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry):
"""Update listener."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
dev_id = _device_id_by_identifiers(device_entry.identifiers)
ent_reg = er.async_get(hass)
entities = {
ent.unique_id: ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
if dev_id in ent.unique_id
}
for entity_id in entities.values():
ent_reg.async_remove(entity_id)
if dev_id not in config_entry.data[CONF_DEVICES]:
_LOGGER.info(
"Device %s not found in config entry: finalizing device removal", dev_id
)
return True
# host = config_entry.data[CONF_DEVICES][dev_id][CONF_HOST]
# await hass.data[DOMAIN][config_entry.entry_id].devices[host].close()
new_data = config_entry.data.copy()
new_data[CONF_DEVICES].pop(dev_id)
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
hass.config_entries.async_update_entry(
config_entry,
data=new_data,
)
_LOGGER.info("Device %s removed.", dev_id)
return True
async def async_remove_orphan_entities(hass, entry):
"""Remove entities associated with config entry that has been removed."""
return
ent_reg = er.async_get(hass)
entities = {
ent.unique_id: ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id)
}
_LOGGER.info("ENTITIES ORPHAN %s", entities)
return
for entity in entry.data[CONF_ENTITIES]:
if entity[CONF_ID] in entities:
del entities[entity[CONF_ID]]
for entity_id in entities.values():
ent_reg.async_remove(entity_id)
def _run_async_listen(hass: HomeAssistant, entry: ConfigEntry):
"""Start the listing events"""
@callback
def _event_filtter(data: dr.EventDeviceRegistryUpdatedData) -> bool:
device_reg = dr.async_get(hass).async_get(data["device_id"])
is_entry = device_reg and entry.entry_id in device_reg.config_entries
return data["action"] == "update" and is_entry
async def device_state_changed(event: Event[dr.EventDeviceRegistryUpdatedData]):
"""Close connection if device disabled."""
if not "disabled_by" in event.data["changes"]:
return
device_registry = dr.async_get(hass).async_get(event.data["device_id"])
hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id]
dev_id = _device_id_by_identifiers(device_registry.identifiers)
host_ip = entry.data[CONF_DEVICES][dev_id][CONF_HOST]
if cid := entry.data[CONF_DEVICES][dev_id].get(CONF_NODE_ID):
host_ip = f"{host_ip}_{cid}"
device = hass_localtuya.devices.get(host_ip)
if device and device_registry.disabled:
# If this is a gateway or fake gateway then reload entry to start using another device as GW.
if device.sub_devices or (device.gateway and device.gateway.id == dev_id):
await hass.config_entries.async_reload(entry.entry_id)
else:
await device.close()
return hass.bus.async_listen(
dr.EVENT_DEVICE_REGISTRY_UPDATED, device_state_changed, _event_filtter
)
def _device_id_by_identifiers(identifiers: set[tuple[str, str]]):
"""Return localtuya device ID by device registry identifiers."""
return list(identifiers)[0][1].split("_")[-1]
@callback
def async_config_entry_by_device_id(hass: HomeAssistant, device_id: str):
"""Look up config entry by device id."""
current_entries = hass.config_entries.async_entries(DOMAIN)
for entry in current_entries:
if device_id in entry.data[CONF_DEVICES]:
return entry
# Search for gateway_id
for dev_conf in entry.data[CONF_DEVICES].values():
if (gw_id := dev_conf.get(CONF_GATEWAY_ID)) and gw_id == device_id:
return entry
return None
@callback
def async_device_id_by_entity_id(hass: HomeAssistant, entity_id: str):
"""Look up config entry by device id."""
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
if device := dev_reg.async_get(ent_reg.async_get(entity_id).device_id):
return list(device.identifiers)[0][1].split("_")[-1]
return None
@callback
def check_if_device_disabled(hass: HomeAssistant, entry: ConfigEntry, dev_id: str):
"""Return whether if the device disabled or not."""
ent_reg = er.async_get(hass)
entries = er.async_entries_for_config_entry(ent_reg, entry.entry_id)
ha_device_id: str = None
for entity in entries:
if dev_id in entity.unique_id:
ha_device_id = entity.device_id
break
if ha_device_id and (device := dr.async_get(hass).async_get(ha_device_id)):
return device.disabled