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

1354 lines
52 KiB
Python

"""Config flow for LocalTuya integration integration."""
import asyncio
import errno
import logging
import time
from importlib import import_module
from functools import partial
from collections.abc import Coroutine
from typing import Any
from copy import deepcopy
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
SelectOptionDict,
)
import voluptuous as vol
from homeassistant import exceptions
from homeassistant.core import callback, HomeAssistant
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_DEVICE_ID,
CONF_DEVICES,
CONF_ENTITIES,
CONF_FRIENDLY_NAME,
CONF_ENTITY_CATEGORY,
CONF_HOST,
CONF_ICON,
CONF_ID,
CONF_NAME,
CONF_PLATFORM,
CONF_REGION,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
EntityCategory,
)
from .coordinator import HassLocalTuyaData
from .core import pytuya
from .core.cloud_api import TUYA_ENDPOINTS, TuyaCloudApi
from .core.helpers import templates, get_gateway_by_deviceid, gen_localtuya_entities
from .const import (
ATTR_UPDATED_AT,
CONF_ADD_DEVICE,
CONF_CONFIGURE_CLOUD,
CONF_DPS_STRINGS,
CONF_EDIT_DEVICE,
CONF_ENABLE_ADD_ENTITIES,
CONF_ENABLE_DEBUG,
CONF_GATEWAY_ID,
CONF_LOCAL_KEY,
CONF_MANUAL_DPS,
CONF_MODEL,
CONF_NODE_ID,
CONF_NO_CLOUD,
CONF_PRODUCT_KEY,
CONF_PRODUCT_NAME,
CONF_PROTOCOL_VERSION,
CONF_RESET_DPIDS,
CONF_TUYA_GWID,
CONF_TUYA_IP,
CONF_TUYA_VERSION,
CONF_USER_ID,
DATA_DISCOVERY,
DEFAULT_CATEGORIES,
DOMAIN,
ENTITY_CATEGORY,
PLATFORMS,
SUPPORTED_PROTOCOL_VERSIONS,
CONF_DEVICE_SLEEP_TIME,
)
from .discovery import discover
_LOGGER = logging.getLogger(__name__)
ENTRIES_VERSION = 4
PLATFORM_TO_ADD = "platform_to_add"
USE_TEMPLATE = "use_template"
TEMPLATES = "templates"
NO_ADDITIONAL_ENTITIES = "no_additional_entities"
SELECTED_DEVICE = "selected_device"
EXPORT_CONFIG = "export_config"
TUYA_CATEGORY = "category"
DEVICE_CLOUD_DATA = "device_cloud_data"
# Using list method so we can translate options.
CONFIGURE_MENU = [CONF_ADD_DEVICE, CONF_EDIT_DEVICE, CONF_CONFIGURE_CLOUD]
def col_to_select(
opt_list: dict | list, multi_select=False, is_dps=False, custom_value=False
) -> SelectSelector:
"""Convert collections to SelectSelectorConfig."""
if type(opt_list) == dict:
return SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(value=str(v), label=k) for k, v in opt_list.items()
],
mode=SelectSelectorMode.DROPDOWN,
custom_value=custom_value,
multiple=True if multi_select else False,
)
)
elif type(opt_list) == list:
# value used the same method as func available_dps_string, no spaces values.
return SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(
value=str(kv).split(" ")[0] if is_dps else str(kv),
label=str(kv),
)
for kv in opt_list
],
mode=SelectSelectorMode.DROPDOWN,
custom_value=custom_value,
multiple=True if multi_select else False,
)
)
CLOUD_CONFIGURE_SCHEMA = vol.Schema(
{
vol.Required(CONF_REGION, default="eu"): col_to_select(TUYA_ENDPOINTS),
vol.Optional(CONF_CLIENT_ID): cv.string,
vol.Optional(CONF_CLIENT_SECRET): cv.string,
vol.Optional(CONF_USER_ID): cv.string,
vol.Optional(CONF_USERNAME, default=DOMAIN): cv.string,
vol.Required(CONF_NO_CLOUD, default=False): bool,
}
)
DEVICE_SCHEMA = vol.Schema(
{
vol.Required(CONF_FRIENDLY_NAME): cv.string,
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_DEVICE_ID): cv.string,
vol.Required(CONF_LOCAL_KEY): cv.string,
vol.Required(CONF_PROTOCOL_VERSION, default="auto"): col_to_select(
["auto"] + sorted(SUPPORTED_PROTOCOL_VERSIONS)
),
vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): str,
vol.Optional(CONF_DEVICE_SLEEP_TIME): int,
vol.Optional(CONF_NODE_ID, default=None): vol.Any(None, cv.string),
}
)
PICK_ENTITY_SCHEMA = vol.Schema(
{vol.Required(PLATFORM_TO_ADD, default="switch"): col_to_select(PLATFORMS)}
)
CONF_MASS_CONFIGURE = "mass_configure"
MASS_CONFIGURE_SCHEMA = {vol.Optional(CONF_MASS_CONFIGURE, default=False): bool}
CUSTOM_DEVICE = {"Add Device Manually": "..."}
class LocaltuyaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LocalTuya integration."""
VERSION = ENTRIES_VERSION
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get options flow for this handler."""
return LocalTuyaOptionsFlowHandler(config_entry)
def __init__(self):
"""Initialize a new LocaltuyaConfigFlow."""
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
placeholders = {}
if user_input is not None:
if user_input.get(CONF_NO_CLOUD):
for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
user_input[i] = ""
return await self._create_entry(user_input)
cloud_api, res = await attempt_cloud_connection(user_input)
if not res:
return await self._create_entry(user_input)
errors["base"] = res["reason"]
# 1004 = Secret, 1106 = USER ID, 2009 = Client ID
if "1106" in res["msg"]:
res["msg"] = f"{res['msg']} Check UserID or country code!"
if "1004" in res["msg"]:
res["msg"] = f"{res['msg']} Check Secret Key!"
placeholders = {"msg": res["msg"]}
defaults = {}
defaults.update(user_input or {})
return self.async_show_form(
step_id="user",
data_schema=schema_defaults(CLOUD_CONFIGURE_SCHEMA, **defaults),
errors=errors,
description_placeholders=placeholders,
)
async def _create_entry(self, user_input):
"""Register new entry."""
# if self._async_current_entries():
# return self.async_abort(reason="already_configured")
await self.async_set_unique_id(user_input.get(CONF_USER_ID))
self._abort_if_unique_id_configured()
user_input[CONF_DEVICES] = {}
return self.async_create_entry(
title=user_input.get(CONF_USERNAME),
data=user_input,
)
async def async_step_import(self, user_input):
"""Handle import from YAML."""
_LOGGER.error(
"Configuration via YAML file is no longer supported by this integration."
)
class LocalTuyaOptionsFlowHandler(OptionsFlow):
"""Handle options flow for LocalTuya integration."""
def __init__(self, config_entry: ConfigEntry):
"""Initialize localtuya options flow."""
self._entry_id = config_entry.entry_id
self.selected_device = None
self.nodeID = None
self.editing_device: bool = False
self.device_data: dict = None
self.dps_strings = []
self.selected_platform = None
self.discovered_devices = {}
self.entities = []
self.use_template = False
self.template_device = None
@property
def localtuya_data(self) -> HassLocalTuyaData:
return self.hass.data[DOMAIN][self._entry_id]
@property
def cloud_data(self) -> TuyaCloudApi:
return self.localtuya_data.cloud_data
async def async_step_init(self, user_input=None):
"""Manage basic options."""
configure_menu = CONFIGURE_MENU.copy()
# Remove Reconfigure existing device option if there is no existed devices.
if not self.config_entry.data[CONF_DEVICES]:
configure_menu.pop(configure_menu.index(CONF_EDIT_DEVICE))
if not self.config_entry.data.get(CONF_NO_CLOUD, True):
self.hass.async_create_task(self.cloud_data.async_get_devices_list())
return self.async_show_menu(step_id="init", menu_options=configure_menu)
async def async_step_configure_cloud(self, user_input=None):
"""Handle the initial step."""
errors = {}
placeholders = {}
if user_input is not None:
username = user_input.get(CONF_USERNAME)
if user_input.get(CONF_NO_CLOUD):
new_data = self.config_entry.data.copy()
new_data.update(user_input)
for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
new_data[i] = ""
return self._update_entry(new_data, new_title=username)
cloud_api, res = await attempt_cloud_connection(user_input)
if not res:
new_data = self.config_entry.data.copy()
new_data.update(user_input)
cloud_devs = cloud_api.device_list
for dev_id, dev in new_data[CONF_DEVICES].items():
if CONF_MODEL not in dev and dev_id in cloud_devs:
model = cloud_devs[dev_id].get(CONF_PRODUCT_NAME)
new_data[CONF_DEVICES][dev_id][CONF_MODEL] = model
return self._update_entry(new_data, new_title=username)
errors["base"] = res["reason"]
placeholders = {"msg": res["msg"]}
defaults = self.config_entry.data.copy()
defaults.update(user_input or {})
defaults[CONF_NO_CLOUD] = False
return self.async_show_form(
step_id="configure_cloud",
data_schema=schema_defaults(CLOUD_CONFIGURE_SCHEMA, **defaults),
errors=errors,
description_placeholders=placeholders,
)
async def async_step_add_device(self, user_input=None):
"""Handle adding a new device."""
# Use cache if available or fallback to manual discovery
self.editing_device = False
self.selected_device = None
errors = {}
if user_input is not None:
if user_input[SELECTED_DEVICE] != CUSTOM_DEVICE["Add Device Manually"]:
self.selected_device = user_input[SELECTED_DEVICE]
if user_input.pop(CONF_MASS_CONFIGURE, False):
# Handle auto configure all recognized devices.
await self.cloud_data.async_get_devices_dps_query()
devices, fails = await setup_localtuya_devices(
self.hass,
self.config_entry.entry_id,
self.discovered_devices,
self.cloud_data.device_list,
log_fails=True,
)
if devices:
devices_sucessed, devices_fails = "", ""
for sucess_dev in devices.values():
devices_sucessed += f"\n{sucess_dev[CONF_FRIENDLY_NAME]}"
for fail_dev in fails.values():
devices_fails += f"\n{fail_dev['name']}: {fail_dev['reason']}"
msg = f"Succeeded devices: ``{len(devices)}``\n ```{devices_sucessed}\n```"
if fails:
msg += f" \n Failed devices: ``{len(fails)}``\n ```{devices_fails}\n```"
return await self.async_step_confirm(
msg=msg,
confirm_callback=self._update_entry,
callback_args=(devices, CONF_DEVICES),
)
return await self.async_step_configure_device()
self.discovered_devices = {}
data = self.hass.data.get(DOMAIN)
if data and DATA_DISCOVERY in data:
self.discovered_devices = data[DATA_DISCOVERY].devices
else:
self.discovered_devices, errors = await discover_devices()
allDevices = mergeDevicesList(
self.discovered_devices, self.cloud_data.device_list
)
self.discovered_devices = allDevices
devices = {}
# To avoid duplicated entities we will get all devices in every hub.
entries = self.hass.config_entries.async_entries(DOMAIN)
configured_Devices = []
for entry in entries:
for devID in entry.data[CONF_DEVICES].keys():
configured_Devices.append(devID)
for dev_id, dev in allDevices.items():
if dev_id not in configured_Devices:
if dev.get(CONF_NODE_ID, None) is not None:
devices[dev_id] = "Sub Device"
else:
devices[dev_id] = dev.get(CONF_TUYA_IP, "")
return self.async_show_form(
step_id="add_device",
data_schema=devices_schema(devices, self.cloud_data.device_list),
errors=errors,
)
async def async_step_edit_device(self, user_input=None):
"""Handle editing a device."""
self.editing_device = True
# Use cache if available or fallback to manual discovery
errors = {}
if user_input is not None:
self.selected_device = user_input[SELECTED_DEVICE]
dev_conf = self.config_entry.data[CONF_DEVICES][self.selected_device]
self.dps_strings = dev_conf.get(CONF_DPS_STRINGS, gen_dps_strings())
self.entities = dev_conf[CONF_ENTITIES]
return await self.async_step_configure_device()
devices = {}
for dev_id, configured_dev in self.config_entry.data[CONF_DEVICES].items():
if configured_dev.get(CONF_NODE_ID, None):
devices[dev_id] = "Sub Device"
else:
devices[dev_id] = configured_dev[CONF_HOST]
return self.async_show_form(
step_id="edit_device",
data_schema=devices_schema(
devices,
self.cloud_data.device_list,
False,
self.config_entry.data[CONF_DEVICES],
),
errors=errors,
)
async def async_step_device_setup_method(self, user_input=None):
"""Manage basic options."""
DEVICE_SETUP_METHOD = [
"auto_configure_device",
"pick_entity_type",
"choose_template",
]
return self.async_show_menu(
step_id="device_setup_method",
menu_options=DEVICE_SETUP_METHOD,
)
async def async_step_configure_device(self, user_input=None):
"""Handle input of basic info."""
errors = {}
placeholders = {}
dev_id = self.selected_device
cloud_devs = self.cloud_data.device_list
if user_input is not None:
try:
self.device_data = user_input.copy()
self.selected_device: str = dev_id or user_input.get(CONF_DEVICE_ID)
self.nodeID: str = self.nodeID or user_input.get(CONF_NODE_ID)
if dev_id is not None:
if dev_id in cloud_devs:
self.device_data[CONF_MODEL] = cloud_devs[dev_id].get(
CONF_PRODUCT_NAME
)
# Pulls some of device data that aren't required from user in config_flow.
if device := self.discovered_devices.get(dev_id):
self.device_data[CONF_PRODUCT_KEY] = device.get("productKey")
if gateway_id := device.get(CONF_GATEWAY_ID):
self.device_data[CONF_GATEWAY_ID] = gateway_id
# Handle Inputs on edit device mode.
if self.editing_device:
dev_config: dict = self.config_entry.data[CONF_DEVICES].get(
dev_id, {}
)
if self.device_data.pop(EXPORT_CONFIG, False):
dev_config = self.config_entry.data[CONF_DEVICES][dev_id].copy()
await self.hass.async_add_executor_job(
templates.export_config,
dev_config,
self.device_data[CONF_FRIENDLY_NAME],
)
return self.async_create_entry(title="", data={})
# We will restore device details if it's already existed!
for res_conf in [CONF_GATEWAY_ID, CONF_MODEL, CONF_PRODUCT_KEY]:
if dev_config.get(res_conf):
self.device_data[res_conf] = dev_config.get(res_conf)
# Remove the values that assigned as "- or empty space"
for rm_conf in [CONF_RESET_DPIDS, CONF_MANUAL_DPS]:
if rm_conf in user_input and user_input[rm_conf] in ["-", " "]:
self.device_data.pop(rm_conf)
self.dps_strings = merge_dps_manual_strings(
self.device_data.get(CONF_MANUAL_DPS, ""), self.dps_strings
)
if self.device_data.pop(CONF_ENABLE_ADD_ENTITIES, False):
self.editing_device = False
user_input[CONF_DEVICE_ID] = dev_id
self.device_data.update(
{
CONF_DEVICE_ID: dev_id,
CONF_NODE_ID: self.nodeID,
CONF_DPS_STRINGS: self.dps_strings,
}
)
return await self.async_step_pick_entity_type()
self.device_data.update(
{
CONF_DEVICE_ID: dev_id,
CONF_NODE_ID: self.nodeID,
CONF_DPS_STRINGS: self.dps_strings,
CONF_ENTITIES: [],
}
)
if len(user_input[CONF_ENTITIES]) == 0:
# If user unchecked all entities.
return self.async_abort(reason="no_entities")
if user_input[CONF_ENTITIES]:
entity_ids = [
int(e.split(":")[0]) for e in user_input[CONF_ENTITIES]
]
if self.use_template:
device_config = self.template_device
else:
device_config = self.config_entry.data[CONF_DEVICES][dev_id]
self.entities = [
entity
for entity in device_config[CONF_ENTITIES]
if int(entity[CONF_ID]) in entity_ids
]
return await self.async_step_configure_entity()
valid_data = await validate_input(
self.hass, self.config_entry.entry_id, user_input
)
self.dps_strings = valid_data[CONF_DPS_STRINGS]
# We will also get protocol version from valid date in case auto used.
self.device_data[CONF_PROTOCOL_VERSION] = valid_data[
CONF_PROTOCOL_VERSION
]
return await self.async_step_device_setup_method()
# return await self.async_step_pick_entity_type()
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except EmptyDpsList:
errors["base"] = "empty_dps"
except (OSError, ValueError, pytuya.DecodeError) as ex:
_LOGGER.debug("Unexpected exception: %s", ex)
placeholders["ex"] = str(ex)
errors["base"] = "unknown"
except Exception as ex:
_LOGGER.debug("Unexpected exception: %s", ex)
raise ex
defaults = {}
if self.editing_device:
# If selected device exists as a config entry, load config from it
defaults = (
self.device_data
if self.use_template
else self.config_entry.data[CONF_DEVICES][dev_id].copy()
)
self.nodeID = defaults.get(CONF_NODE_ID, None)
placeholders["for_device"] = f" for device `{dev_id}`"
if self.nodeID:
placeholders.update(
{"for_device": f"for Sub-Device `{dev_id}.NodeID {self.nodeID}`"}
)
if dev_id in cloud_devs:
cloud_local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
if defaults[CONF_LOCAL_KEY] != cloud_local_key:
_LOGGER.info(
"New local_key detected: new %s vs old %s",
cloud_local_key,
defaults[CONF_LOCAL_KEY],
)
defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
note = "\nNOTE: a new local_key has been retrieved using cloud API"
placeholders = {"for_device": f" for device `{dev_id}`.{note}"}
if self.nodeID:
placeholders = {
"for_device": f" for sub-device `{dev_id}.\nNodeID {self.nodeID}.{note}`"
}
schema = schema_defaults(options_schema(self.entities), **defaults)
else:
# user_in will restore input if an error occurred instead of clears all fields.
user_in = user_input or {}
defaults[CONF_PROTOCOL_VERSION] = user_in.get(CONF_PROTOCOL_VERSION, "auto")
defaults[CONF_HOST] = user_in.get(CONF_HOST, "")
defaults[CONF_DEVICE_ID] = user_in.get(CONF_DEVICE_ID, "")
defaults[CONF_LOCAL_KEY] = user_in.get(CONF_LOCAL_KEY, "")
defaults[CONF_FRIENDLY_NAME] = user_in.get(CONF_FRIENDLY_NAME, "")
defaults[CONF_NODE_ID] = user_in.get(CONF_NODE_ID, "")
if defaults[CONF_DEVICE_ID] in [cloud_devs, self.selected_device]:
dev_id = defaults[CONF_DEVICE_ID]
if dev_id is not None and dev_id in self.discovered_devices:
# Insert default values from discovery and cloud if present
device = self.discovered_devices.get(dev_id, {})
defaults[CONF_HOST] = device.get(CONF_TUYA_IP)
defaults[CONF_DEVICE_ID] = device.get(CONF_TUYA_GWID)
defaults[CONF_PROTOCOL_VERSION] = device.get(CONF_TUYA_VERSION)
defaults[CONF_NODE_ID] = device.get(CONF_NODE_ID, None)
if dev_id in cloud_devs:
defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
defaults[CONF_FRIENDLY_NAME] = cloud_devs[dev_id].get(CONF_NAME)
schema = schema_defaults(DEVICE_SCHEMA, **defaults)
placeholders["for_device"] = ""
return self.async_show_form(
step_id="configure_device",
data_schema=schema,
errors=errors,
description_placeholders=placeholders,
)
async def async_step_auto_configure_device(self, user_input=None):
"""Handle asking which templates to use"""
errors = {}
placeholders = {}
# Gather the information
is_cloud = not self.config_entry.data.get(CONF_NO_CLOUD)
dev_id = self.selected_device
category = None
node_id = self.nodeID
device_data = self.cloud_data.device_list.get(dev_id)
if device_data:
category = self.cloud_data.device_list[dev_id].get(TUYA_CATEGORY, "")
localtuya_data = {
DEVICE_CLOUD_DATA: device_data,
CONF_DPS_STRINGS: self.dps_strings,
CONF_FRIENDLY_NAME: self.device_data.get(CONF_FRIENDLY_NAME),
}
dev_data = gen_localtuya_entities(localtuya_data, category)
# Process to add the device to localtuya HA Config.
if dev_data:
self.entities = dev_data
return await self.async_step_pick_entity_type(
{NO_ADDITIONAL_ENTITIES: True}
)
if not is_cloud:
err_msg = f"This feature requires cloud API setup for now"
elif not device_data:
err_msg = f"Couldn't find your device in the cloud account you using"
elif not category:
err_msg = f"Your device category isn't supported"
elif not dev_data:
err_msg = f"Couldn't find the data for your device category: {category}."
placeholders = {"err_msg": err_msg}
return self.async_show_menu(
step_id="auto_configure_device",
menu_options=["device_setup_method"],
description_placeholders=placeholders,
)
async def async_step_pick_entity_type(self, user_input=None):
"""Handle asking if user wants to add another entity."""
if user_input is not None:
if user_input.get(NO_ADDITIONAL_ENTITIES):
config = {
**self.device_data,
CONF_DPS_STRINGS: self.dps_strings,
CONF_ENTITIES: self.entities,
}
dev_id = self.device_data.get(CONF_DEVICE_ID)
new_data = self.config_entry.data.copy()
new_data[CONF_DEVICES].update({dev_id: config})
return self._update_entry(new_data)
if user_input.get(USE_TEMPLATE):
return await self.async_step_choose_template()
self.selected_platform = user_input[PLATFORM_TO_ADD]
return await self.async_step_configure_entity()
# Add a checkbox that allows bailing out from config flow if at least one
# entity has been added
schema = PICK_ENTITY_SCHEMA
if self.selected_platform is not None:
schema = schema.extend(
{vol.Required(NO_ADDITIONAL_ENTITIES, default=True): bool}
)
return self.async_show_form(step_id="pick_entity_type", data_schema=schema)
async def async_step_choose_template(self, user_input=None):
"""Handle asking which templates to use"""
if user_input is not None:
self.use_template = True
filename = user_input.get(TEMPLATES)
_config = await self.hass.async_add_executor_job(
templates.import_config, filename
)
dev_conf = self.device_data
dev_conf[CONF_ENTITIES] = _config
dev_conf[CONF_DPS_STRINGS] = self.dps_strings
dev_conf[CONF_NODE_ID] = self.nodeID
self.device_data = dev_conf
self.entities = dev_conf[CONF_ENTITIES]
self.template_device = self.device_data
self.editing_device = True
return await self.async_step_configure_device()
templates_list = await self.hass.async_add_executor_job(
templates.list_templates
)
schema = vol.Schema(
{vol.Required(TEMPLATES): col_to_select(templates_list, custom_value=True)}
)
return self.async_show_form(step_id="choose_template", data_schema=schema)
async def async_step_entity(self, user_input=None):
"""Manage entity settings."""
errors = {}
if user_input is not None:
entity = strip_dps_values(user_input, self.dps_strings)
entity[CONF_ID] = self.current_entity[CONF_ID]
entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM]
self.device_data[CONF_ENTITIES].append(entity)
if len(self.entities) == len(self.device_data[CONF_ENTITIES]):
return self._update_entry(self.device_data)
schema = await platform_schema(
self.hass, self.current_entity[CONF_PLATFORM], self.dps_strings, False
)
return self.async_show_form(
step_id="entity",
errors=errors,
data_schema=schema_defaults(
schema, self.dps_strings, **self.current_entity
),
description_placeholders={
"id": int(self.current_entity[CONF_ID]),
"platform": self.current_entity[CONF_PLATFORM],
},
)
async def async_step_configure_entity(self, user_input=None):
"""Manage entity settings."""
errors = {}
if user_input is not None:
if self.editing_device:
entity = strip_dps_values(user_input, self.dps_strings)
entity[CONF_ID] = self.current_entity[CONF_ID]
entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM]
entity[CONF_ICON] = self.current_entity.get(CONF_ICON, "")
self.device_data[CONF_ENTITIES].append(entity)
if len(self.entities) == len(self.device_data[CONF_ENTITIES]):
# finished editing device. Let's store the new config entry....
dev_id = self.device_data[CONF_DEVICE_ID]
new_data = self.config_entry.data.copy()
entry_id = self.config_entry.entry_id
# Removing the unwanted entities.
entitesNames = [
name.get(CONF_FRIENDLY_NAME)
for name in self.device_data[CONF_ENTITIES]
]
ent_reg = er.async_get(self.hass)
reg_entities = {
ent.unique_id: ent.entity_id
for ent in er.async_entries_for_config_entry(ent_reg, entry_id)
if dev_id in ent.unique_id
and ent.original_name not in entitesNames
}
for entity_id in reg_entities.values():
ent_reg.async_remove(entity_id)
new_data[CONF_DEVICES][dev_id] = self.device_data
return self._update_entry(new_data)
else:
user_input[CONF_PLATFORM] = self.selected_platform
self.entities.append(strip_dps_values(user_input, self.dps_strings))
# new entity added. Let's check if there are more left...
user_input = None
if len(self.available_dps_strings()) == 0:
user_input = {NO_ADDITIONAL_ENTITIES: True}
return await self.async_step_pick_entity_type(user_input)
if self.editing_device:
schema = await platform_schema(
self.hass, self.current_entity[CONF_PLATFORM], self.dps_strings, False
)
schema = schema_defaults(schema, self.dps_strings, **self.current_entity)
placeholders = {
"entity": f"entity with DP {int(self.current_entity[CONF_ID])}",
"platform": self.current_entity[CONF_PLATFORM],
}
else:
available_dps = self.available_dps_strings()
schema = await platform_schema(
self.hass, self.selected_platform, available_dps
)
placeholders = {
"entity": "an entity",
"platform": self.selected_platform,
}
return self.async_show_form(
step_id="configure_entity",
data_schema=schema,
errors=errors,
description_placeholders=placeholders,
)
async def async_step_confirm(
self,
msg: str,
confirm_callback: Coroutine = None,
callback_args: tuple[Any, ...] | None = None,
):
"""Create a confirmation config flow page. If submitted, the `confirm_callback` will be called."""
if confirm_callback:
if callback_args:
self._confirm_callback = partial(confirm_callback, *callback_args)
else:
self._confirm_callback = confirm_callback
placeholders = {}
placeholders["message"] = msg
if not msg:
return self._confirm_callback()
return self.async_show_form(
step_id="confirm", description_placeholders=placeholders
)
# menu = ["confirm", "init"]
# return self.async_show_menu(
# step_id="confirm", menu_options=menu, description_placeholders=placeholders
# )
@callback
def _update_entry(self, new_data, target_obj="", new_title=""):
"""Update entry data and save etnry,"""
_data = deepcopy(dict(self.config_entry.data))
if target_obj:
_data[target_obj].update(new_data)
else:
_data.update(new_data)
_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
self.hass.config_entries.async_update_entry(
self.config_entry, data=_data, title=new_title or self.config_entry.title
)
return self.async_create_entry(title=new_title, data={})
def available_dps_strings(self):
"""Return list of DPs use by the device's entities."""
available_dps = []
used_dps = [str(entity[CONF_ID]) for entity in self.entities]
for dp_string in self.dps_strings:
dp = dp_string.split(" ")[0]
if dp not in used_dps:
available_dps.append(dp_string)
return available_dps
@property
def current_entity(self):
"""Existing configuration for entity currently being edited."""
return self.entities[len(self.device_data[CONF_ENTITIES])]
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""
class EmptyDpsList(exceptions.HomeAssistantError):
"""Error to indicate no datapoints found."""
async def setup_localtuya_devices(
hass: HomeAssistant,
entry_id: str,
discovered_devices: dict,
devices_cloud_data: dict,
log_fails=False,
):
"""Return a dict of configured devices ready to import into devices data."""
# Store devices data
devices_cfg = []
devices = {}
fails = {}
def update_fails(dev_id: str, reason: str, msg: str = None):
name = devices_cloud_data[dev_id].get(CONF_NAME, dev_id)
fails.update({dev_id: {"name": name, "reason": reason}})
if log_fails:
msg = f"[ name: {name} — id: {dev_id} — reason: {reason or repr(reason)}]"
_LOGGER.warning(f"Failed to configure device: {msg}")
# To avoid duplicated entities we will get all devices in every hub.
entries = hass.config_entries.async_entries(DOMAIN)
configured_Devices = []
for entry in entries:
for devID in entry.data[CONF_DEVICES].keys():
configured_Devices.append(devID)
for dev_id, data in discovered_devices.items():
# Skip configured devices.
if dev_id in configured_Devices:
continue
if dev_cloud_data := devices_cloud_data.get(dev_id):
# Create localtuya devices data and store them into devices_config.
device_data = {
CONF_FRIENDLY_NAME: dev_cloud_data.get(CONF_NAME, dev_id),
CONF_DEVICE_ID: dev_id,
CONF_HOST: data[CONF_TUYA_IP],
CONF_LOCAL_KEY: dev_cloud_data.get(CONF_LOCAL_KEY),
CONF_PROTOCOL_VERSION: data[CONF_TUYA_VERSION],
CONF_ENABLE_DEBUG: False,
CONF_NODE_ID: dev_cloud_data.get(CONF_NODE_ID),
CONF_MODEL: dev_cloud_data.get(CONF_MODEL),
CONF_PRODUCT_KEY: data.get("productKey"),
}
# If device is sub and has Gateway ID store gatewayID
if sub_gwid := data.get(CONF_GATEWAY_ID):
device_data.update({CONF_GATEWAY_ID: sub_gwid})
# Store device to device_data.
devices_cfg.append(device_data)
# Connect to the devices to ensure the are usable.
validate_devices = [validate_input(hass, entry_id, dev) for dev in devices_cfg]
results = await asyncio.gather(*validate_devices, return_exceptions=True)
# Merge test results with devices config
for i in range(len(results)):
dev_id = devices_cfg[i].get(CONF_DEVICE_ID)
if not isinstance(results[i], dict):
update_fails(dev_id, results[i])
continue
devices.update({dev_id: {**devices_cfg[i], **results[i]}})
# Configure entities.
for dev_id, dev_data in deepcopy(devices).items():
category = devices_cloud_data[dev_id].get("category")
dev_data[DEVICE_CLOUD_DATA] = devices_cloud_data[dev_id]
if category and (dps_strings := dev_data.get(CONF_DPS_STRINGS, False)):
dev_entites = gen_localtuya_entities(dev_data, category)
# Configure entities fails
if not dev_entites:
devices.pop(dev_id)
update_fails(dev_id, f"no configured entities: {dev_entites} - {category}")
continue
# Add configured entities
devices[dev_id].update({CONF_ENTITIES: dev_entites})
return devices, fails
async def discover_devices() -> tuple[dict[str, dict], dict[str, str]]:
"""Start discovering Tuya devices within the network"""
errors = {}
discovered_devices = {}
try:
discovered_devices = await discover()
except OSError as ex:
if ex.errno == errno.EADDRINUSE:
errors["base"] = "address_in_use"
else:
errors["base"] = "discovery_failed"
except Exception as ex:
_LOGGER.exception("discovery failed: %s", ex)
errors["base"] = "discovery_failed"
return discovered_devices, errors
def devices_schema(
discovered_devices, cloud_devices_list, add_custom_device=True, existed_devices={}
):
"""Create schema for devices step."""
known_devices = {}
devices = {}
for dev_id, dev_host in discovered_devices.items():
dev_name = dev_id
# when editing devices get INFOS from stored!.
if not add_custom_device and dev_id in existed_devices.keys():
dev_name = existed_devices[dev_id].get(CONF_FRIENDLY_NAME, dev_id)
elif dev_id in cloud_devices_list.keys():
dev_name = cloud_devices_list[dev_id][CONF_NAME]
known_devices[f"{dev_name} ({dev_host})"] = dev_id
continue
devices[f"{dev_name} ({dev_host})"] = dev_id
known_devices = dict(sorted(known_devices.items()))
devices = {**known_devices, **devices}
if add_custom_device:
devices.update(CUSTOM_DEVICE)
else: # Sort devices in edit mode.
devices = dict(sorted(devices.items()))
schema = vol.Schema(
{
vol.Required(SELECTED_DEVICE): col_to_select(devices),
}
)
return schema.extend(MASS_CONFIGURE_SCHEMA) if known_devices else schema
def mergeDevicesList(localList: dict, cloudList: dict, addSubDevices=True) -> dict:
"""Merge CloudDevices with Discovered LocalDevices (in specific ways)!"""
# try Get SubDevices.
newList = localList.copy()
for _devID, _devData in cloudList.items():
try:
is_online = _devData.get("online", None)
sub_device = _devData.get(CONF_NODE_ID, False)
# We skip offline devices and already merged devices.
if not is_online or _devID in localList:
continue
# Make sure the device isn't already in localList.
if addSubDevices and sub_device:
# infrared are ir remote sub-devices
if _devData.get(TUYA_CATEGORY, "").startswith("infrared"):
continue
gateway = get_gateway_by_deviceid(_devID, cloudList)
local_gw = localList.get(gateway.id)
if local_gw:
# Create a data for sub_device [cloud and local gateway] to merge it with discovered devices.
dev_data = {
_devID: {
CONF_TUYA_IP: local_gw.get(CONF_TUYA_IP),
CONF_TUYA_GWID: _devID,
CONF_TUYA_VERSION: local_gw.get(CONF_TUYA_VERSION, "auto"),
CONF_NODE_ID: _devData.get(CONF_NODE_ID, None),
CONF_GATEWAY_ID: local_gw.get(CONF_TUYA_GWID),
}
}
newList.update(dev_data)
except Exception as ex:
_LOGGER.debug(f"An error occurred while trying to pull sub-devices {ex}")
continue
return newList
def options_schema(entities):
"""Create schema for options."""
entity_names = [
f"{entity[CONF_ID]}: {entity[CONF_FRIENDLY_NAME]}" for entity in entities
]
return vol.Schema(
{
vol.Required(CONF_FRIENDLY_NAME): cv.string,
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_LOCAL_KEY): cv.string,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): col_to_select(
sorted(SUPPORTED_PROTOCOL_VERSIONS)
),
vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): cv.string,
vol.Optional(CONF_DEVICE_SLEEP_TIME): int,
vol.Required(
CONF_ENTITIES, description={"suggested_value": entity_names}
): cv.multi_select(entity_names),
# col_to_select(entity_names, multi_select=True)
vol.Required(CONF_ENABLE_ADD_ENTITIES, default=False): bool,
vol.Optional(EXPORT_CONFIG, default=False): bool,
}
)
def schema_defaults(schema, dps_list=None, **defaults):
"""Create a new schema with default values filled in."""
copy = schema.extend({})
for field, field_type in copy.schema.items():
if isinstance(field_type, vol.In):
value = None
for dps in dps_list or []:
if dps.startswith(f"{defaults.get(field)} "):
value = dps
break
if value in field_type.container:
field.default = vol.default_factory(value)
continue
if field.schema in defaults:
field.default = vol.default_factory(defaults[field])
return copy
def dps_string_list(dps_data: dict[str, dict], cloud_dp_codes: dict[str, dict]) -> list:
"""Return list of friendly DPS values."""
strs = []
# Merge DPs that found through cloud with local.
for dp, func in cloud_dp_codes.items():
# Default Manual dp value is -1, we will replace it if it in cloud.
if dp not in dps_data or dps_data.get(dp) == -1:
value = func.get("value", "")
dps_data[dp] = f"{value}, cloud pull"
for dp, value in dps_data.items():
if (dp_data := cloud_dp_codes.get(dp)) and (code := dp_data.get("code")):
strs.append(f"{dp} ( code: {code} , value: {value} )")
else:
strs.append(f"{dp} ( value: {value} )")
return sorted(strs, key=lambda i: int(i.split()[0]))
def gen_dps_strings():
"""Generate list of DPS values."""
return [f"{dp} (value: ?)" for dp in range(1, 256)]
def strip_dps_values(user_input, dps_strings):
"""Remove values and keep only index for DPS config items."""
stripped = {}
for field, value in user_input.items():
if value in dps_strings:
stripped[field] = int(user_input[field].split(" ")[0])
else:
stripped[field] = user_input[field]
return stripped
def merge_dps_manual_strings(manual_dps: list, dps_strings: list):
"""Split manual_dps by comma and assign -1 as default value. Return merged with dps string."""
manual_list = []
avaliable_dps = [dp.split(" ")[0] for dp in dps_strings]
for dp in manual_dps.split(","):
dp = dp.strip()
if dp.isdigit() and dp not in avaliable_dps and dp != "0":
manual_list.append(f"{dp} ( value: -1 )")
return sorted(dps_strings + manual_list, key=lambda i: int(i.split(" ")[0]))
async def platform_schema(
hass: HomeAssistant, platform, dps_strings, allow_id=True, yaml=False
):
"""Generate input validation schema for a platform."""
# decide default value of device by platform.
schema = {}
if yaml:
# In YAML mode we force the specified platform to match flow schema
schema[vol.Required(CONF_PLATFORM)] = col_to_select([platform])
if allow_id:
schema[vol.Required(CONF_ID)] = col_to_select(dps_strings, is_dps=True)
schema[vol.Optional(CONF_FRIENDLY_NAME, default="")] = vol.Any(None, cv.string)
schema[
vol.Required(CONF_ENTITY_CATEGORY, default=str(default_category(platform)))
] = col_to_select(ENTITY_CATEGORY)
plat_schema = await hass.async_add_import_executor_job(
flow_schema, platform, dps_strings
)
return vol.Schema(schema).extend(plat_schema)
def default_category(_platform):
"""Auto Select default category depends on the platform."""
if any(_platform in i for i in DEFAULT_CATEGORIES["CONTROL"]):
return None
elif any(_platform in i for i in DEFAULT_CATEGORIES["CONFIG"]):
return EntityCategory.CONFIG
elif any(_platform in i for i in DEFAULT_CATEGORIES["DIAGNOSTIC"]):
return EntityCategory.DIAGNOSTIC
else:
return None
def flow_schema(platform, dps_strings):
"""Return flow schema for a specific platform."""
integration_module = ".".join(__name__.split(".")[:-1])
return import_module("." + platform, integration_module).flow_schema(dps_strings)
async def validate_input(hass: HomeAssistant, entry_id, data):
"""Validate the user input allows us to connect."""
logger = pytuya.ContextualLogger()
logger.set_logger(_LOGGER, data[CONF_DEVICE_ID], True, data[CONF_FRIENDLY_NAME])
detected_dps = {}
error = None
interface = None
reset_ids = None
close = True
bypass_connection = False # On users risk, only used for low-power power devices
bypass_handshake = False # In-case device is passive.
cid = data.get(CONF_NODE_ID, None)
localtuya_devices = hass.data[DOMAIN][entry_id].devices
try:
conf_protocol = data[CONF_PROTOCOL_VERSION]
auto_protocol = conf_protocol == "auto"
# If sub device we will search if gateway is existed if not create new connection.
if (
cid
and (existed_interface := localtuya_devices.get(data[CONF_HOST]))
and existed_interface.connected
and not existed_interface.is_connecting
):
interface = existed_interface._interface
close = False
else:
# If 'auto' will be loop through supported protocols.
for ver in SUPPORTED_PROTOCOL_VERSIONS:
try:
version = ver if auto_protocol else conf_protocol
interface = await asyncio.wait_for(
pytuya.connect(
data[CONF_HOST],
data[CONF_DEVICE_ID],
data[CONF_LOCAL_KEY],
float(version),
data[CONF_ENABLE_DEBUG],
),
5,
)
detected_dps = await interface.detect_available_dps(cid=cid)
# Break the loop if input isn't auto.
if not auto_protocol:
break
# If Auto: using DPS detected we will assume this is the correct version if dps found.
if len(detected_dps) > 0:
# Set the conf_protocol to the worked version to return it and update self.device_data.
conf_protocol = version
break
# If connection to host is failed raise wrong address.
except (OSError, ValueError, pytuya.DecodeError) as ex:
error = ex
break
except:
continue
finally:
if not auto_protocol and data.get(CONF_DEVICE_SLEEP_TIME, 0) > 0:
bypass_connection = True
if not error and not interface:
error = InvalidAuth
if CONF_RESET_DPIDS in data:
reset_ids_str = data[CONF_RESET_DPIDS].split(",")
reset_ids = []
for reset_id in reset_ids_str:
reset_ids.append(int(reset_id.strip()))
logger.debug(
"Reset DPIDs configured: %s (%s)", data[CONF_RESET_DPIDS], reset_ids
)
try:
# If reset dpids set - then assume reset is needed before status.
if (reset_ids is not None) and (len(reset_ids) > 0):
logger.debug("Resetting command for DP IDs: %s", reset_ids)
# Assume we want to request status updated for the same set of DP_IDs as the reset ones.
interface.set_updatedps_list(reset_ids)
# Reset the interface
await interface.reset(reset_ids, cid=cid)
# Detect any other non-manual DPS strings
if not detected_dps:
detected_dps = await interface.detect_available_dps(cid=cid)
except (ValueError, pytuya.DecodeError) as ex:
error = ex
except Exception as ex:
logger.debug(f"No DPS able to be detected {ex}")
detected_dps = {}
# if manual DPs are set, merge these.
# detected_dps_device used to prevent user from bypass handshake manual dps.
detected_dps_device = detected_dps.copy()
logger.debug("Detected DPS: %s", detected_dps)
if CONF_MANUAL_DPS in data:
manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")]
logger.debug(
"Manual DPS Setting: %s (%s)", data[CONF_MANUAL_DPS], manual_dps_list
)
# merge the lists
for new_dps in manual_dps_list + (reset_ids or []):
# If the DPS not in the detected dps list, then add with a
# default value indicating that it has been manually added
if str(new_dps) == "0":
bypass_handshake = True
continue
if str(new_dps) not in detected_dps:
detected_dps[new_dps] = -1
except (ConnectionRefusedError, ConnectionResetError) as ex:
raise CannotConnect from ex
except (OSError, ValueError, pytuya.DecodeError) as ex:
error = ex
finally:
if interface and close:
await interface.close()
# Get DP descriptions from the cloud, if the device is there.
cloud_dp_codes = {}
cloud_data: TuyaCloudApi = hass.data[DOMAIN][entry_id].cloud_data
if (dev_id := data.get(CONF_DEVICE_ID)) in cloud_data.device_list:
cloud_dp_codes = await cloud_data.async_get_device_functions(dev_id)
# Indicate an error if no datapoints found as the rest of the flow
# won't work in this case
if not bypass_connection and error:
raise error
# If bypass handshake. otherwise raise failed to make handshake with device.
# --- Cloud: We will use the DPS found on cloud if exists.
# --- No cloud: user will have to input the DPS manually.
if not detected_dps_device and not (
(cloud_dp_codes or detected_dps) and bypass_handshake
):
raise EmptyDpsList
logger.debug("Total DPS: %s", detected_dps)
return {
CONF_DPS_STRINGS: dps_string_list(detected_dps, cloud_dp_codes),
CONF_PROTOCOL_VERSION: conf_protocol,
}
async def attempt_cloud_connection(user_input):
"""Create device."""
cloud_api = TuyaCloudApi(
user_input.get(CONF_REGION),
user_input.get(CONF_CLIENT_ID),
user_input.get(CONF_CLIENT_SECRET),
user_input.get(CONF_USER_ID),
)
msg, res = await cloud_api.async_connect()
if res != "ok":
return cloud_api, {"reason": msg, "msg": res}
return cloud_api, {}