1354 lines
52 KiB
Python
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, {}
|