config_flow.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  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 = 21
  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_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.__cloud_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_local()
  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. return await self.async_step_choose_device()
  198. async def async_step_choose_device(self, user_input=None):
  199. errors = {}
  200. if user_input is not None:
  201. device_choice = self.__cloud_devices[user_input["device_id"]]
  202. if device_choice["ip"] != "":
  203. # This is a directly addable device.
  204. if user_input["hub_id"] == "None":
  205. device_choice["ip"] = ""
  206. self.__cloud_device = device_choice
  207. return await self.async_step_search()
  208. else:
  209. # Show error if user selected a hub.
  210. errors["base"] = "does_not_need_hub"
  211. # Fall through to reshow the form.
  212. else:
  213. # This is an indirectly addressable device. Need to know which hub it is connected to.
  214. if user_input["hub_id"] != "None":
  215. hub_choice = self.__cloud_devices[user_input["hub_id"]]
  216. # Populate node_id or uuid and local_key from the child
  217. # device to pass on complete information to the local step.
  218. hub_choice["ip"] = ""
  219. hub_choice[CONF_DEVICE_CID] = (
  220. device_choice["node_id"] or device_choice["uuid"]
  221. )
  222. if device_choice.get(CONF_LOCAL_KEY):
  223. hub_choice[CONF_LOCAL_KEY] = device_choice[CONF_LOCAL_KEY]
  224. # Communicate the sub device product id to help match the
  225. # correect device config in the next step.
  226. hub_choice["product_id"] = device_choice["product_id"]
  227. self.__cloud_device = hub_choice
  228. return await self.async_step_search()
  229. else:
  230. # Show error if user did not select a hub.
  231. errors["base"] = "needs_hub"
  232. # Fall through to reshow the form.
  233. device_list = []
  234. for key in self.__cloud_devices.keys():
  235. device_entry = self.__cloud_devices[key]
  236. if device_entry.get("exists"):
  237. continue
  238. if device_entry[CONF_LOCAL_KEY] != "":
  239. if device_entry["online"]:
  240. device_list.append(
  241. SelectOptionDict(
  242. value=key,
  243. label=f"{device_entry['name']} ({device_entry['product_name']})",
  244. )
  245. )
  246. else:
  247. device_list.append(
  248. SelectOptionDict(
  249. value=key,
  250. label=f"{device_entry['name']} ({device_entry['product_name']}) OFFLINE",
  251. )
  252. )
  253. _LOGGER.debug(f"Device count: {len(device_list)}")
  254. if len(device_list) == 0:
  255. return self.async_abort(reason="no_devices")
  256. device_selector = SelectSelector(
  257. SelectSelectorConfig(options=device_list, mode=SelectSelectorMode.DROPDOWN)
  258. )
  259. hub_list = []
  260. hub_list.append(SelectOptionDict(value="None", label="None"))
  261. for key in self.__cloud_devices.keys():
  262. hub_entry = self.__cloud_devices[key]
  263. if hub_entry["is_hub"]:
  264. hub_list.append(
  265. SelectOptionDict(
  266. value=key,
  267. label=f"{hub_entry['name']} ({hub_entry['product_name']})",
  268. )
  269. )
  270. _LOGGER.debug(f"Hub count: {len(hub_list) - 1}")
  271. hub_selector = SelectSelector(
  272. SelectSelectorConfig(options=hub_list, mode=SelectSelectorMode.DROPDOWN)
  273. )
  274. # Build form
  275. fields: OrderedDict[vol.Marker, Any] = OrderedDict()
  276. fields[vol.Required("device_id")] = device_selector
  277. fields[vol.Required("hub_id")] = hub_selector
  278. return self.async_show_form(
  279. step_id="choose_device",
  280. data_schema=vol.Schema(fields),
  281. errors=errors or {},
  282. last_step=False,
  283. )
  284. @property
  285. def _device_name_placeholder(self) -> str:
  286. """Return device name placeholder for step descriptions."""
  287. if self.__cloud_device and self.__cloud_device.get("product_name"):
  288. parts = []
  289. if self.__cloud_device.get("name"):
  290. parts.append(self.__cloud_device["name"])
  291. parts.append(self.__cloud_device["product_name"])
  292. return "**" + " — ".join(parts) + "**\n\n"
  293. return ""
  294. async def async_step_search(self, user_input=None):
  295. if user_input is not None:
  296. # Current IP is the WAN IP which is of no use. Need to try and discover to the local IP.
  297. # This scan will take 18s with the default settings. If we cannot find the device we
  298. # will just leave the IP address blank and hope the user can discover the IP by other
  299. # means such as router device IP assignments.
  300. _LOGGER.debug(
  301. f"Scanning network to get IP address for {self.__cloud_device.get('id', 'DEVICE_KEY_UNAVAILABLE')}."
  302. )
  303. self.__cloud_device["ip"] = ""
  304. try:
  305. local_device = await self.hass.async_add_executor_job(
  306. scan_for_device, self.__cloud_device.get("id")
  307. )
  308. except OSError:
  309. local_device = {"ip": None, "version": ""}
  310. if local_device.get("ip"):
  311. _LOGGER.debug(f"Found: {local_device}")
  312. self.__cloud_device["ip"] = local_device.get("ip")
  313. self.__cloud_device["version"] = local_device.get("version")
  314. if not self.__cloud_device.get(CONF_DEVICE_CID):
  315. self.__cloud_device["local_product_id"] = local_device.get(
  316. "productKey"
  317. )
  318. else:
  319. _LOGGER.warning(
  320. f"Could not find device: {self.__cloud_device.get('id', 'DEVICE_KEY_UNAVAILABLE')}"
  321. )
  322. return await self.async_step_local()
  323. return self.async_show_form(
  324. step_id="search",
  325. data_schema=vol.Schema({}),
  326. description_placeholders={
  327. "device_name": self._device_name_placeholder,
  328. },
  329. errors={},
  330. last_step=False,
  331. )
  332. async def async_step_local(self, user_input=None):
  333. errors = {}
  334. devid_opts = {}
  335. host_opts = {"default": ""}
  336. key_opts = {}
  337. proto_opts = {"default": "auto"}
  338. polling_opts = {"default": False}
  339. devcid_opts = {}
  340. if self.__cloud_device is not None:
  341. # We already have some or all of the device settings from the cloud flow. Set them into the defaults.
  342. devid_opts = {"default": self.__cloud_device.get("id")}
  343. host_opts = {"default": self.__cloud_device.get("ip")}
  344. key_opts = {"default": self.__cloud_device.get(CONF_LOCAL_KEY)}
  345. if self.__cloud_device.get("version"):
  346. proto_opts = {"default": str(self.__cloud_device.get("version"))}
  347. if self.__cloud_device.get(CONF_DEVICE_CID):
  348. devcid_opts = {"default": self.__cloud_device.get(CONF_DEVICE_CID)}
  349. if user_input is not None:
  350. proto = user_input.get(CONF_PROTOCOL_VERSION)
  351. if proto != "auto":
  352. user_input[CONF_PROTOCOL_VERSION] = float(proto)
  353. self.device = await async_test_connection(user_input, self.hass)
  354. if self.device:
  355. self.data = user_input
  356. # If auto mode found a working protocol, save it so future
  357. # HA restarts connect directly without re-cycling all versions.
  358. self._auto_detected_protocol = None
  359. if (
  360. user_input.get(CONF_PROTOCOL_VERSION) == "auto"
  361. and self.device._protocol_configured != "auto"
  362. ):
  363. self._auto_detected_protocol = self.device._protocol_configured
  364. self.data = {
  365. **self.data,
  366. CONF_PROTOCOL_VERSION: self._auto_detected_protocol,
  367. }
  368. if self.__cloud_device:
  369. if self.__cloud_device.get("product_id"):
  370. self.device.set_detected_product_id(
  371. self.__cloud_device.get("product_id")
  372. )
  373. if self.__cloud_device.get("local_product_id"):
  374. self.device.set_detected_product_id(
  375. self.__cloud_device.get("local_product_id")
  376. )
  377. await self.async_set_unique_id(
  378. user_input.get(CONF_DEVICE_CID, user_input[CONF_DEVICE_ID])
  379. )
  380. self._abort_if_unique_id_configured()
  381. return await self.async_step_select_type()
  382. else:
  383. errors["base"] = "connection"
  384. devid_opts["default"] = user_input[CONF_DEVICE_ID]
  385. host_opts["default"] = user_input[CONF_HOST]
  386. key_opts["default"] = user_input[CONF_LOCAL_KEY]
  387. if CONF_DEVICE_CID in user_input:
  388. devcid_opts["default"] = user_input[CONF_DEVICE_CID]
  389. proto_opts["default"] = str(user_input[CONF_PROTOCOL_VERSION])
  390. polling_opts["default"] = user_input[CONF_POLL_ONLY]
  391. return self.async_show_form(
  392. step_id="local",
  393. data_schema=vol.Schema(
  394. {
  395. vol.Required(CONF_DEVICE_ID, **devid_opts): str,
  396. vol.Required(CONF_HOST, **host_opts): str,
  397. vol.Required(CONF_LOCAL_KEY, **key_opts): str,
  398. vol.Required(
  399. CONF_PROTOCOL_VERSION,
  400. **proto_opts,
  401. ): vol.In(["auto"] + [str(v) for v in API_PROTOCOL_VERSIONS]),
  402. vol.Required(CONF_POLL_ONLY, **polling_opts): bool,
  403. vol.Optional(CONF_DEVICE_CID, **devcid_opts): str,
  404. }
  405. ),
  406. description_placeholders={
  407. "device_details_url": DEVICE_DETAILS_URL,
  408. "device_name": self._device_name_placeholder,
  409. },
  410. errors=errors,
  411. )
  412. async def async_step_select_type(self, user_input=None):
  413. if user_input is not None:
  414. # Value is a compound key: "config_type||manufacturer||model"
  415. parts = user_input[CONF_TYPE].split("||", 2)
  416. self.data[CONF_TYPE] = parts[0]
  417. if len(parts) > 1 and parts[1]:
  418. self.data[CONF_MANUFACTURER] = parts[1]
  419. if len(parts) > 2 and parts[2]:
  420. self.data[CONF_MODEL] = parts[2]
  421. return await self.async_step_choose_entities()
  422. all_matches = []
  423. best_match = 0
  424. best_matching_type = None
  425. best_matching_key = None
  426. for dev_type in await self.device.async_possible_types():
  427. q = dev_type.match_quality(
  428. self.device._get_cached_state(),
  429. self.device._product_ids,
  430. )
  431. for manufacturer, model in dev_type.product_display_entries(
  432. self.device._product_ids
  433. ):
  434. key = f"{dev_type.config_type}||{manufacturer or ''}||{model or ''}"
  435. parts = [p for p in [manufacturer, model] if p]
  436. if parts:
  437. label = f"{' '.join(parts)} ({dev_type.config_type})"
  438. else:
  439. label = f"{dev_type.name} ({dev_type.config_type})"
  440. all_matches.append((SelectOptionDict(value=key, label=label), q))
  441. if q > best_match:
  442. best_match = q
  443. best_matching_type = dev_type.config_type
  444. best_matching_key = key
  445. all_matches.sort(key=lambda x: x[1], reverse=True)
  446. type_options = [opt for opt, _ in all_matches]
  447. best_match = int(best_match)
  448. dps = self.device._get_cached_state()
  449. if self.__cloud_device:
  450. _LOGGER.warning(
  451. "Adding %s device with product id %s",
  452. self.__cloud_device.get("product_name", "UNKNOWN"),
  453. self.__cloud_device.get("product_id", "UNKNOWN"),
  454. )
  455. if self.__cloud_device.get("local_product_id") and self.__cloud_device.get(
  456. "local_product_id"
  457. ) != self.__cloud_device.get("product_id"):
  458. _LOGGER.warning(
  459. "Local product id differs from cloud: %s",
  460. self.__cloud_device.get("local_product_id"),
  461. )
  462. try:
  463. self.init_cloud()
  464. model = await self.cloud.async_get_datamodel(
  465. self.__cloud_device.get("id"),
  466. )
  467. if model:
  468. _LOGGER.warning(
  469. "Partial cloud device spec:\n%s",
  470. log_json(model),
  471. )
  472. except Exception as e:
  473. _LOGGER.warning(
  474. "Unable to fetch data model from cloud: %s %s",
  475. type(e).__name__,
  476. e,
  477. )
  478. _LOGGER.warning(
  479. "Device matches %s with quality of %d%%. LOCAL DPS: %s",
  480. best_matching_type,
  481. best_match,
  482. log_json(dps),
  483. )
  484. _LOGGER.warning(
  485. "Include the previous log messages with any new device request to https://github.com/make-all/tuya-local/issues/",
  486. )
  487. if type_options:
  488. detected = getattr(self, "_auto_detected_protocol", None)
  489. schema = vol.Schema(
  490. {
  491. vol.Required(
  492. CONF_TYPE,
  493. default=best_matching_key,
  494. ): SelectSelector(SelectSelectorConfig(options=type_options)),
  495. }
  496. )
  497. if detected:
  498. return self.async_show_form(
  499. step_id="select_type_auto_detected",
  500. data_schema=schema,
  501. description_placeholders={
  502. "detected_protocol": str(detected),
  503. "device_name": self._device_name_placeholder,
  504. },
  505. )
  506. return self.async_show_form(
  507. step_id="select_type",
  508. data_schema=schema,
  509. description_placeholders={
  510. "device_name": self._device_name_placeholder,
  511. },
  512. )
  513. else:
  514. return self.async_abort(reason="not_supported")
  515. async def async_step_select_type_auto_detected(self, user_input=None):
  516. return await self.async_step_select_type(user_input)
  517. async def async_step_choose_entities(self, user_input=None):
  518. config = await self.hass.async_add_executor_job(
  519. get_config,
  520. self.data[CONF_TYPE],
  521. )
  522. if user_input is not None:
  523. title = user_input[CONF_NAME]
  524. del user_input[CONF_NAME]
  525. return self.async_create_entry(
  526. title=title, data={**self.data, **user_input}
  527. )
  528. default_name = config.name
  529. if self.__cloud_device and self.__cloud_device.get("name"):
  530. default_name = self.__cloud_device["name"]
  531. schema = {vol.Required(CONF_NAME, default=default_name): str}
  532. return self.async_show_form(
  533. step_id="choose_entities",
  534. data_schema=vol.Schema(schema),
  535. description_placeholders={
  536. "device_name": self._device_name_placeholder,
  537. },
  538. )
  539. @staticmethod
  540. @callback
  541. def async_get_options_flow(config_entry: ConfigEntry):
  542. return OptionsFlowHandler()
  543. class OptionsFlowHandler(OptionsFlow):
  544. def __init__(self):
  545. """Initialize options flow."""
  546. pass
  547. async def async_step_init(self, user_input=None):
  548. return await self.async_step_user(user_input)
  549. async def async_step_user(self, user_input=None):
  550. """Manage the options."""
  551. errors = {}
  552. config = {**self.config_entry.data, **self.config_entry.options}
  553. if user_input is not None:
  554. proto = user_input.get(CONF_PROTOCOL_VERSION)
  555. if proto != "auto":
  556. user_input[CONF_PROTOCOL_VERSION] = float(proto)
  557. config = {**config, **user_input}
  558. device = await async_test_connection(config, self.hass)
  559. if device:
  560. return self.async_create_entry(title="", data=user_input)
  561. else:
  562. errors["base"] = "connection"
  563. schema = {
  564. vol.Required(
  565. CONF_LOCAL_KEY,
  566. default=config.get(CONF_LOCAL_KEY, ""),
  567. ): str,
  568. vol.Required(CONF_HOST, default=config.get(CONF_HOST, "")): str,
  569. vol.Required(
  570. CONF_PROTOCOL_VERSION,
  571. default=str(config.get(CONF_PROTOCOL_VERSION, "auto")),
  572. ): vol.In(["auto"] + [str(v) for v in API_PROTOCOL_VERSIONS]),
  573. vol.Required(
  574. CONF_POLL_ONLY, default=config.get(CONF_POLL_ONLY, False)
  575. ): bool,
  576. }
  577. cfg = await self.hass.async_add_executor_job(
  578. get_config,
  579. config[CONF_TYPE],
  580. )
  581. if cfg is None:
  582. return self.async_abort(reason="not_supported")
  583. return self.async_show_form(
  584. step_id="user",
  585. data_schema=vol.Schema(schema),
  586. description_placeholders={"device_details_url": DEVICE_DETAILS_URL},
  587. errors=errors,
  588. )
  589. def create_test_device(hass: HomeAssistant, config: dict):
  590. """Set up a tuya device based on passed in config."""
  591. subdevice_id = config.get(CONF_DEVICE_CID)
  592. device = TuyaLocalDevice(
  593. "Test",
  594. config[CONF_DEVICE_ID],
  595. config[CONF_HOST],
  596. config[CONF_LOCAL_KEY],
  597. config[CONF_PROTOCOL_VERSION],
  598. subdevice_id,
  599. hass,
  600. True,
  601. )
  602. return device
  603. async def async_test_connection(config: dict, hass: HomeAssistant):
  604. domain_data = hass.data.get(DOMAIN)
  605. existing = domain_data.get(get_device_id(config)) if domain_data else None
  606. if existing and existing.get("device"):
  607. _LOGGER.info("Pausing existing device to test new connection parameters")
  608. existing["device"].pause()
  609. await asyncio.sleep(5)
  610. retval = None
  611. if config.get(CONF_PROTOCOL_VERSION) == "auto":
  612. # Test each protocol with a fresh device object. Reusing one device
  613. # object across protocol rotations causes 3.4/3.5 handshakes to fail:
  614. # the shared tinytuya object carries stale internal state from the
  615. # prior connection attempts.
  616. for proto in API_PROTOCOL_VERSIONS:
  617. proto_config = {**config, CONF_PROTOCOL_VERSION: proto}
  618. device = None
  619. try:
  620. device = await hass.async_add_executor_job(
  621. create_test_device, hass, proto_config
  622. )
  623. await device.async_refresh()
  624. if device.has_returned_state:
  625. retval = device
  626. break
  627. except Exception as e:
  628. _LOGGER.debug("Protocol %s test failed with %s %s", proto, type(e), e)
  629. if device is not None:
  630. device._api.set_socketPersistent(False)
  631. if device._api.parent:
  632. device._api.parent.set_socketPersistent(False)
  633. else:
  634. try:
  635. device = await hass.async_add_executor_job(
  636. create_test_device,
  637. hass,
  638. config,
  639. )
  640. await device.async_refresh()
  641. retval = device if device.has_returned_state else None
  642. except Exception as e:
  643. _LOGGER.warning("Connection test failed with %s %s", type(e), e)
  644. if existing and existing.get("device"):
  645. _LOGGER.info("Restarting device after test")
  646. existing["device"].resume()
  647. return retval
  648. def scan_for_device(devid):
  649. return tinytuya.find_device(dev_id=devid)