config_flow.py 28 KB

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