config_flow.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. import asyncio
  2. import logging
  3. from collections import OrderedDict
  4. from typing import Any
  5. import tinytuya
  6. import voluptuous as vol
  7. from homeassistant.config_entries import (
  8. CONN_CLASS_LOCAL_PUSH,
  9. ConfigEntry,
  10. ConfigFlow,
  11. OptionsFlow,
  12. )
  13. from homeassistant.const import CONF_HOST, CONF_NAME
  14. from homeassistant.core import HomeAssistant, callback
  15. from homeassistant.data_entry_flow import FlowResult
  16. from homeassistant.helpers.selector import (
  17. QrCodeSelector,
  18. QrCodeSelectorConfig,
  19. QrErrorCorrectionLevel,
  20. SelectOptionDict,
  21. SelectSelector,
  22. SelectSelectorConfig,
  23. SelectSelectorMode,
  24. )
  25. from . import DOMAIN
  26. from .cloud import Cloud
  27. from .const import (
  28. API_PROTOCOL_VERSIONS,
  29. CONF_DEVICE_CID,
  30. CONF_DEVICE_ID,
  31. CONF_LOCAL_KEY,
  32. CONF_MANUFACTURER,
  33. CONF_MODEL,
  34. CONF_POLL_ONLY,
  35. CONF_PROTOCOL_VERSION,
  36. CONF_TYPE,
  37. CONF_USER_CODE,
  38. DATA_STORE,
  39. )
  40. from .device import TuyaLocalDevice
  41. from .helpers.config import get_device_id
  42. from .helpers.device_config import get_config
  43. from .helpers.log import log_json
  44. _LOGGER = logging.getLogger(__name__)
  45. DEVICE_DETAILS_URL = (
  46. "https://github.com/make-all/tuya-local/blob/main/DEVICE_DETAILS.md"
  47. "#finding-your-device-id-and-local-key"
  48. )
  49. class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
  50. VERSION = 13
  51. MINOR_VERSION = 16
  52. CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH
  53. device = None
  54. data = {}
  55. __qr_code: str | None = None
  56. __cloud_devices: dict[str, Any] = {}
  57. __cloud_device: dict[str, Any] | None = None
  58. def __init__(self) -> None:
  59. """Initialize the config flow."""
  60. self.cloud = None
  61. def init_cloud(self):
  62. if self.cloud is None:
  63. self.cloud = Cloud(self.hass)
  64. async def async_step_user(self, user_input=None):
  65. errors = {}
  66. if self.hass.data.get(DOMAIN) is None:
  67. self.hass.data[DOMAIN] = {}
  68. if self.hass.data[DOMAIN].get(DATA_STORE) is None:
  69. self.hass.data[DOMAIN][DATA_STORE] = {}
  70. if user_input is not None:
  71. mode = user_input.get("setup_mode")
  72. if mode == "cloud" or mode == "cloud_fresh_login":
  73. self.init_cloud()
  74. try:
  75. if mode == "cloud_fresh_login":
  76. # Force a fresh login
  77. self.cloud.logout()
  78. if self.cloud.is_authenticated:
  79. self.__cloud_devices = await self.cloud.async_get_devices()
  80. return await self.async_step_choose_device()
  81. except Exception as e:
  82. # Re-authentication is needed.
  83. _LOGGER.warning("Connection test failed with %s %s", type(e), e)
  84. _LOGGER.warning("Re-authentication is required.")
  85. return await self.async_step_cloud()
  86. if mode == "manual":
  87. return await self.async_step_local()
  88. # Build form
  89. fields: OrderedDict[vol.Marker, Any] = OrderedDict()
  90. fields[vol.Required("setup_mode")] = SelectSelector(
  91. SelectSelectorConfig(
  92. options=["cloud", "manual", "cloud_fresh_login"],
  93. mode=SelectSelectorMode.LIST,
  94. translation_key="setup_mode",
  95. )
  96. )
  97. return self.async_show_form(
  98. step_id="user",
  99. data_schema=vol.Schema(fields),
  100. errors=errors or {},
  101. last_step=False,
  102. )
  103. async def async_step_cloud(
  104. self, user_input: dict[str, Any] | None = None
  105. ) -> FlowResult:
  106. """Step user."""
  107. errors = {}
  108. placeholders = {}
  109. self.init_cloud()
  110. if user_input is not None:
  111. response = await self.cloud.async_get_qr_code(user_input[CONF_USER_CODE])
  112. if response:
  113. self.__qr_code = response
  114. return await self.async_step_scan()
  115. errors["base"] = "login_error"
  116. placeholders = self.cloud.last_error
  117. else:
  118. user_input = {}
  119. return self.async_show_form(
  120. step_id="cloud",
  121. data_schema=vol.Schema(
  122. {
  123. vol.Required(
  124. CONF_USER_CODE, default=user_input.get(CONF_USER_CODE, "")
  125. ): str,
  126. }
  127. ),
  128. errors=errors,
  129. description_placeholders=placeholders,
  130. )
  131. async def async_step_scan(
  132. self, user_input: dict[str, Any] | None = None
  133. ) -> FlowResult:
  134. """Step scan."""
  135. if user_input is None:
  136. return self.async_show_form(
  137. step_id="scan",
  138. data_schema=vol.Schema(
  139. {
  140. vol.Optional("QR"): QrCodeSelector(
  141. config=QrCodeSelectorConfig(
  142. data=f"tuyaSmart--qrLogin?token={self.__qr_code}",
  143. scale=5,
  144. error_correction_level=QrErrorCorrectionLevel.QUARTILE,
  145. )
  146. )
  147. }
  148. ),
  149. )
  150. self.init_cloud()
  151. if not await self.cloud.async_login():
  152. # Try to get a new QR code on failure
  153. response = await self.cloud.async_get_qr_code()
  154. errors = {"base": "login_error"}
  155. placeholders = self.cloud.last_error
  156. if response:
  157. self.__qr_code = response
  158. return self.async_show_form(
  159. step_id="scan",
  160. errors=errors,
  161. data_schema=vol.Schema(
  162. {
  163. vol.Optional("QR"): QrCodeSelector(
  164. config=QrCodeSelectorConfig(
  165. data=f"tuyaSmart--qrLogin?token={self.__qr_code}",
  166. scale=5,
  167. error_correction_level=QrErrorCorrectionLevel.QUARTILE,
  168. )
  169. )
  170. }
  171. ),
  172. description_placeholders=placeholders,
  173. )
  174. self.__cloud_devices = await self.cloud.async_get_devices()
  175. return await self.async_step_choose_device()
  176. async def async_step_choose_device(self, user_input=None):
  177. errors = {}
  178. if user_input is not None:
  179. device_choice = self.__cloud_devices[user_input["device_id"]]
  180. if device_choice["ip"] != "":
  181. # This is a directly addable device.
  182. if user_input["hub_id"] == "None":
  183. device_choice["ip"] = ""
  184. self.__cloud_device = device_choice
  185. return await self.async_step_search()
  186. else:
  187. # Show error if user selected a hub.
  188. errors["base"] = "does_not_need_hub"
  189. # Fall through to reshow the form.
  190. else:
  191. # This is an indirectly addressable device. Need to know which hub it is connected to.
  192. if user_input["hub_id"] != "None":
  193. hub_choice = self.__cloud_devices[user_input["hub_id"]]
  194. # Populate node_id or uuid and local_key from the child
  195. # device to pass on complete information to the local step.
  196. hub_choice["ip"] = ""
  197. hub_choice[CONF_DEVICE_CID] = (
  198. device_choice["node_id"] or device_choice["uuid"]
  199. )
  200. if device_choice.get(CONF_LOCAL_KEY):
  201. hub_choice[CONF_LOCAL_KEY] = device_choice[CONF_LOCAL_KEY]
  202. # Communicate the sub device product id to help match the
  203. # correect device config in the next step.
  204. hub_choice["product_id"] = device_choice["product_id"]
  205. self.__cloud_device = hub_choice
  206. return await self.async_step_search()
  207. else:
  208. # Show error if user did not select a hub.
  209. errors["base"] = "needs_hub"
  210. # Fall through to reshow the form.
  211. device_list = []
  212. for key in self.__cloud_devices.keys():
  213. device_entry = self.__cloud_devices[key]
  214. if device_entry.get("exists"):
  215. continue
  216. if device_entry[CONF_LOCAL_KEY] != "":
  217. if device_entry["online"]:
  218. device_list.append(
  219. SelectOptionDict(
  220. value=key,
  221. label=f"{device_entry['name']} ({device_entry['product_name']})",
  222. )
  223. )
  224. else:
  225. device_list.append(
  226. SelectOptionDict(
  227. value=key,
  228. label=f"{device_entry['name']} ({device_entry['product_name']}) OFFLINE",
  229. )
  230. )
  231. _LOGGER.debug(f"Device count: {len(device_list)}")
  232. if len(device_list) == 0:
  233. return self.async_abort(reason="no_devices")
  234. device_selector = SelectSelector(
  235. SelectSelectorConfig(options=device_list, mode=SelectSelectorMode.DROPDOWN)
  236. )
  237. hub_list = []
  238. hub_list.append(SelectOptionDict(value="None", label="None"))
  239. for key in self.__cloud_devices.keys():
  240. hub_entry = self.__cloud_devices[key]
  241. if hub_entry["is_hub"]:
  242. hub_list.append(
  243. SelectOptionDict(
  244. value=key,
  245. label=f"{hub_entry['name']} ({hub_entry['product_name']})",
  246. )
  247. )
  248. _LOGGER.debug(f"Hub count: {len(hub_list) - 1}")
  249. hub_selector = SelectSelector(
  250. SelectSelectorConfig(options=hub_list, mode=SelectSelectorMode.DROPDOWN)
  251. )
  252. # Build form
  253. fields: OrderedDict[vol.Marker, Any] = OrderedDict()
  254. fields[vol.Required("device_id")] = device_selector
  255. fields[vol.Required("hub_id")] = hub_selector
  256. return self.async_show_form(
  257. step_id="choose_device",
  258. data_schema=vol.Schema(fields),
  259. errors=errors or {},
  260. last_step=False,
  261. )
  262. async def async_step_search(self, user_input=None):
  263. if user_input is not None:
  264. # Current IP is the WAN IP which is of no use. Need to try and discover to the local IP.
  265. # This scan will take 18s with the default settings. If we cannot find the device we
  266. # will just leave the IP address blank and hope the user can discover the IP by other
  267. # means such as router device IP assignments.
  268. _LOGGER.debug(
  269. f"Scanning network to get IP address for {self.__cloud_device.get('id', 'DEVICE_KEY_UNAVAILABLE')}."
  270. )
  271. self.__cloud_device["ip"] = ""
  272. try:
  273. local_device = await self.hass.async_add_executor_job(
  274. scan_for_device, self.__cloud_device.get("id")
  275. )
  276. except OSError:
  277. local_device = {"ip": None, "version": ""}
  278. if local_device.get("ip"):
  279. _LOGGER.debug(f"Found: {local_device}")
  280. self.__cloud_device["ip"] = local_device.get("ip")
  281. self.__cloud_device["version"] = local_device.get("version")
  282. if not self.__cloud_device.get(CONF_DEVICE_CID):
  283. self.__cloud_device["local_product_id"] = local_device.get(
  284. "productKey"
  285. )
  286. else:
  287. _LOGGER.warning(
  288. f"Could not find device: {self.__cloud_device.get('id', 'DEVICE_KEY_UNAVAILABLE')}"
  289. )
  290. return await self.async_step_local()
  291. return self.async_show_form(
  292. step_id="search", data_schema=vol.Schema({}), errors={}, last_step=False
  293. )
  294. async def async_step_local(self, user_input=None):
  295. errors = {}
  296. devid_opts = {}
  297. host_opts = {"default": ""}
  298. key_opts = {}
  299. proto_opts = {"default": "auto"}
  300. polling_opts = {"default": False}
  301. devcid_opts = {}
  302. if self.__cloud_device is not None:
  303. # We already have some or all of the device settings from the cloud flow. Set them into the defaults.
  304. devid_opts = {"default": self.__cloud_device.get("id")}
  305. host_opts = {"default": self.__cloud_device.get("ip")}
  306. key_opts = {"default": self.__cloud_device.get(CONF_LOCAL_KEY)}
  307. if self.__cloud_device.get("version"):
  308. proto_opts = {"default": str(self.__cloud_device.get("version"))}
  309. if self.__cloud_device.get(CONF_DEVICE_CID):
  310. devcid_opts = {"default": self.__cloud_device.get(CONF_DEVICE_CID)}
  311. if user_input is not None:
  312. proto = user_input.get(CONF_PROTOCOL_VERSION)
  313. if proto != "auto":
  314. user_input[CONF_PROTOCOL_VERSION] = float(proto)
  315. self.device = await async_test_connection(user_input, self.hass)
  316. if self.device:
  317. self.data = user_input
  318. # If auto mode found a working protocol, save it so future
  319. # HA restarts connect directly without re-cycling all versions.
  320. self._auto_detected_protocol = None
  321. if (
  322. user_input.get(CONF_PROTOCOL_VERSION) == "auto"
  323. and self.device._protocol_configured != "auto"
  324. ):
  325. self._auto_detected_protocol = self.device._protocol_configured
  326. self.data = {
  327. **self.data,
  328. CONF_PROTOCOL_VERSION: self._auto_detected_protocol,
  329. }
  330. if self.__cloud_device:
  331. if self.__cloud_device.get("product_id"):
  332. self.device.set_detected_product_id(
  333. self.__cloud_device.get("product_id")
  334. )
  335. if self.__cloud_device.get("local_product_id"):
  336. self.device.set_detected_product_id(
  337. self.__cloud_device.get("local_product_id")
  338. )
  339. await self.async_set_unique_id(
  340. user_input.get(CONF_DEVICE_CID, user_input[CONF_DEVICE_ID])
  341. )
  342. self._abort_if_unique_id_configured()
  343. return await self.async_step_select_type()
  344. else:
  345. errors["base"] = "connection"
  346. devid_opts["default"] = user_input[CONF_DEVICE_ID]
  347. host_opts["default"] = user_input[CONF_HOST]
  348. key_opts["default"] = user_input[CONF_LOCAL_KEY]
  349. if CONF_DEVICE_CID in user_input:
  350. devcid_opts["default"] = user_input[CONF_DEVICE_CID]
  351. proto_opts["default"] = str(user_input[CONF_PROTOCOL_VERSION])
  352. polling_opts["default"] = user_input[CONF_POLL_ONLY]
  353. return self.async_show_form(
  354. step_id="local",
  355. data_schema=vol.Schema(
  356. {
  357. vol.Required(CONF_DEVICE_ID, **devid_opts): str,
  358. vol.Required(CONF_HOST, **host_opts): str,
  359. vol.Required(CONF_LOCAL_KEY, **key_opts): str,
  360. vol.Required(
  361. CONF_PROTOCOL_VERSION,
  362. **proto_opts,
  363. ): vol.In(["auto"] + [str(v) for v in API_PROTOCOL_VERSIONS]),
  364. vol.Required(CONF_POLL_ONLY, **polling_opts): bool,
  365. vol.Optional(CONF_DEVICE_CID, **devcid_opts): str,
  366. }
  367. ),
  368. description_placeholders={"device_details_url": DEVICE_DETAILS_URL},
  369. errors=errors,
  370. )
  371. async def async_step_select_type(self, user_input=None):
  372. if user_input is not None:
  373. # Value is a compound key: "config_type||manufacturer||model"
  374. parts = user_input[CONF_TYPE].split("||", 2)
  375. self.data[CONF_TYPE] = parts[0]
  376. if len(parts) > 1 and parts[1]:
  377. self.data[CONF_MANUFACTURER] = parts[1]
  378. if len(parts) > 2 and parts[2]:
  379. self.data[CONF_MODEL] = parts[2]
  380. return await self.async_step_choose_entities()
  381. all_matches = []
  382. best_match = 0
  383. best_matching_type = None
  384. best_matching_key = None
  385. has_product_id_match = False
  386. for type in await self.device.async_possible_types():
  387. q = type.match_quality(
  388. self.device._get_cached_state(),
  389. self.device._product_ids,
  390. )
  391. if q > 100:
  392. has_product_id_match = True
  393. for manufacturer, model in type.product_display_entries(
  394. self.device._product_ids
  395. ):
  396. key = f"{type.config_type}||{manufacturer or ''}||{model or ''}"
  397. parts = [p for p in [manufacturer, model] if p]
  398. if parts:
  399. label = f"{' '.join(parts)} ({type.config_type})"
  400. else:
  401. label = f"{type.name} ({type.config_type})"
  402. all_matches.append((SelectOptionDict(value=key, label=label), q))
  403. if q > best_match:
  404. best_match = q
  405. best_matching_type = type.config_type
  406. best_matching_key = key
  407. if has_product_id_match:
  408. type_options = [opt for opt, q in all_matches if q > 100]
  409. else:
  410. type_options = [opt for opt, _ in all_matches]
  411. best_match = int(best_match)
  412. dps = self.device._get_cached_state()
  413. if self.__cloud_device:
  414. _LOGGER.warning(
  415. "Adding %s device with product id %s",
  416. self.__cloud_device.get("product_name", "UNKNOWN"),
  417. self.__cloud_device.get("product_id", "UNKNOWN"),
  418. )
  419. if self.__cloud_device.get("local_product_id") and self.__cloud_device.get(
  420. "local_product_id"
  421. ) != self.__cloud_device.get("product_id"):
  422. _LOGGER.warning(
  423. "Local product id differs from cloud: %s",
  424. self.__cloud_device.get("local_product_id"),
  425. )
  426. try:
  427. self.init_cloud()
  428. model = await self.cloud.async_get_datamodel(
  429. self.__cloud_device.get("id"),
  430. )
  431. if model:
  432. _LOGGER.warning(
  433. "Partial cloud device spec:\n%s",
  434. log_json(model),
  435. )
  436. except Exception as e:
  437. _LOGGER.warning(
  438. "Unable to fetch data model from cloud: %s %s",
  439. type(e).__name__,
  440. e,
  441. )
  442. _LOGGER.warning(
  443. "Device matches %s with quality of %d%%. LOCAL DPS: %s",
  444. best_matching_type,
  445. best_match,
  446. log_json(dps),
  447. )
  448. _LOGGER.warning(
  449. "Include the previous log messages with any new device request to https://github.com/make-all/tuya-local/issues/",
  450. )
  451. if type_options:
  452. detected = getattr(self, "_auto_detected_protocol", None)
  453. schema = vol.Schema(
  454. {
  455. vol.Required(
  456. CONF_TYPE,
  457. default=best_matching_key,
  458. ): SelectSelector(SelectSelectorConfig(options=type_options)),
  459. }
  460. )
  461. if detected:
  462. return self.async_show_form(
  463. step_id="select_type_auto_detected",
  464. data_schema=schema,
  465. description_placeholders={"detected_protocol": str(detected)},
  466. )
  467. return self.async_show_form(
  468. step_id="select_type",
  469. data_schema=schema,
  470. )
  471. else:
  472. return self.async_abort(reason="not_supported")
  473. async def async_step_select_type_auto_detected(self, user_input=None):
  474. return await self.async_step_select_type(user_input)
  475. async def async_step_choose_entities(self, user_input=None):
  476. config = await self.hass.async_add_executor_job(
  477. get_config,
  478. self.data[CONF_TYPE],
  479. )
  480. if user_input is not None:
  481. title = user_input[CONF_NAME]
  482. del user_input[CONF_NAME]
  483. return self.async_create_entry(
  484. title=title, data={**self.data, **user_input}
  485. )
  486. schema = {vol.Required(CONF_NAME, default=config.name): str}
  487. return self.async_show_form(
  488. step_id="choose_entities",
  489. data_schema=vol.Schema(schema),
  490. )
  491. @staticmethod
  492. @callback
  493. def async_get_options_flow(config_entry: ConfigEntry):
  494. return OptionsFlowHandler()
  495. class OptionsFlowHandler(OptionsFlow):
  496. def __init__(self):
  497. """Initialize options flow."""
  498. pass
  499. async def async_step_init(self, user_input=None):
  500. return await self.async_step_user(user_input)
  501. async def async_step_user(self, user_input=None):
  502. """Manage the options."""
  503. errors = {}
  504. config = {**self.config_entry.data, **self.config_entry.options}
  505. if user_input is not None:
  506. proto = user_input.get(CONF_PROTOCOL_VERSION)
  507. if proto != "auto":
  508. user_input[CONF_PROTOCOL_VERSION] = float(proto)
  509. config = {**config, **user_input}
  510. device = await async_test_connection(config, self.hass)
  511. if device:
  512. return self.async_create_entry(title="", data=user_input)
  513. else:
  514. errors["base"] = "connection"
  515. schema = {
  516. vol.Required(
  517. CONF_LOCAL_KEY,
  518. default=config.get(CONF_LOCAL_KEY, ""),
  519. ): str,
  520. vol.Required(CONF_HOST, default=config.get(CONF_HOST, "")): str,
  521. vol.Required(
  522. CONF_PROTOCOL_VERSION,
  523. default=str(config.get(CONF_PROTOCOL_VERSION, "auto")),
  524. ): vol.In(["auto"] + [str(v) for v in API_PROTOCOL_VERSIONS]),
  525. vol.Required(
  526. CONF_POLL_ONLY, default=config.get(CONF_POLL_ONLY, False)
  527. ): bool,
  528. }
  529. cfg = await self.hass.async_add_executor_job(
  530. get_config,
  531. config[CONF_TYPE],
  532. )
  533. if cfg is None:
  534. return self.async_abort(reason="not_supported")
  535. return self.async_show_form(
  536. step_id="user",
  537. data_schema=vol.Schema(schema),
  538. description_placeholders={"device_details_url": DEVICE_DETAILS_URL},
  539. errors=errors,
  540. )
  541. def create_test_device(hass: HomeAssistant, config: dict):
  542. """Set up a tuya device based on passed in config."""
  543. subdevice_id = config.get(CONF_DEVICE_CID)
  544. device = TuyaLocalDevice(
  545. "Test",
  546. config[CONF_DEVICE_ID],
  547. config[CONF_HOST],
  548. config[CONF_LOCAL_KEY],
  549. config[CONF_PROTOCOL_VERSION],
  550. subdevice_id,
  551. hass,
  552. True,
  553. )
  554. return device
  555. async def async_test_connection(config: dict, hass: HomeAssistant):
  556. domain_data = hass.data.get(DOMAIN)
  557. existing = domain_data.get(get_device_id(config)) if domain_data else None
  558. if existing and existing.get("device"):
  559. _LOGGER.info("Pausing existing device to test new connection parameters")
  560. existing["device"].pause()
  561. await asyncio.sleep(5)
  562. retval = None
  563. if config.get(CONF_PROTOCOL_VERSION) == "auto":
  564. # Test each protocol with a fresh device object. Reusing one device
  565. # object across protocol rotations causes 3.4/3.5 handshakes to fail:
  566. # the shared tinytuya object carries stale internal state from the
  567. # prior connection attempts.
  568. for proto in API_PROTOCOL_VERSIONS:
  569. proto_config = {**config, CONF_PROTOCOL_VERSION: proto}
  570. device = None
  571. try:
  572. device = await hass.async_add_executor_job(
  573. create_test_device, hass, proto_config
  574. )
  575. await device.async_refresh()
  576. if device.has_returned_state:
  577. retval = device
  578. break
  579. except Exception as e:
  580. _LOGGER.debug("Protocol %s test failed with %s %s", proto, type(e), e)
  581. if device is not None:
  582. device._api.set_socketPersistent(False)
  583. if device._api.parent:
  584. device._api.parent.set_socketPersistent(False)
  585. else:
  586. try:
  587. device = await hass.async_add_executor_job(
  588. create_test_device,
  589. hass,
  590. config,
  591. )
  592. await device.async_refresh()
  593. retval = device if device.has_returned_state else None
  594. except Exception as e:
  595. _LOGGER.warning("Connection test failed with %s %s", type(e), e)
  596. if existing and existing.get("device"):
  597. _LOGGER.info("Restarting device after test")
  598. existing["device"].resume()
  599. return retval
  600. def scan_for_device(id):
  601. return tinytuya.find_device(dev_id=id)