"""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