4
0

test_config_flow.py 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608
  1. """Tests for the config flow."""
  2. from unittest.mock import AsyncMock
  3. import pytest
  4. import voluptuous as vol
  5. from homeassistant.const import CONF_HOST, CONF_NAME
  6. from homeassistant.data_entry_flow import FlowResultType
  7. from homeassistant.exceptions import ConfigEntryNotReady
  8. from pytest_homeassistant_custom_component.common import MockConfigEntry
  9. from custom_components.tuya_local import (
  10. async_migrate_entry,
  11. async_setup_entry,
  12. async_unload_entry,
  13. config_flow,
  14. get_device_unique_id,
  15. )
  16. from custom_components.tuya_local.const import (
  17. CONF_DEVICE_CID,
  18. CONF_DEVICE_ID,
  19. CONF_LOCAL_KEY,
  20. CONF_POLL_ONLY,
  21. CONF_PROTOCOL_VERSION,
  22. CONF_TYPE,
  23. DOMAIN,
  24. )
  25. # Designed to contain "special" characters that users constantly suspect.
  26. TESTKEY = ")<jO<@)'P1|kR$Kd"
  27. @pytest.fixture(autouse=True)
  28. def auto_enable_custom_integrations(enable_custom_integrations):
  29. yield
  30. @pytest.fixture(autouse=True)
  31. def prevent_task_creation(mocker):
  32. mocker.patch("custom_components.tuya_local.device.TuyaLocalDevice.register_entity")
  33. yield
  34. @pytest.fixture
  35. def bypass_setup(mocker):
  36. """Prevent actual setup of the integration after config flow."""
  37. mocker.patch("custom_components.tuya_local.async_setup_entry", return_value=True)
  38. yield
  39. @pytest.fixture
  40. def bypass_data_fetch(mocker):
  41. """Prevent actual data fetching from the device."""
  42. mocker.patch("tinytuya.Device.status", return_value={"1": True})
  43. yield
  44. @pytest.mark.asyncio
  45. async def test_init_entry(hass, bypass_data_fetch):
  46. """Test initialisation of the config flow."""
  47. entry = MockConfigEntry(
  48. domain=DOMAIN,
  49. version=11,
  50. title="test",
  51. data={
  52. CONF_DEVICE_ID: "deviceid",
  53. CONF_HOST: "hostname",
  54. CONF_LOCAL_KEY: TESTKEY,
  55. CONF_POLL_ONLY: False,
  56. CONF_PROTOCOL_VERSION: "auto",
  57. CONF_TYPE: "kogan_kahtp_heater",
  58. CONF_DEVICE_CID: None,
  59. },
  60. options={},
  61. )
  62. entry.add_to_hass(hass)
  63. await hass.config_entries.async_setup(entry.entry_id)
  64. await hass.async_block_till_done()
  65. assert hass.states.get("climate.test")
  66. assert hass.states.get("lock.test_child_lock")
  67. @pytest.mark.asyncio
  68. @pytest.mark.parametrize("refresh_error", [RuntimeError("boom"), None])
  69. async def test_async_setup_entry_cleans_up_failed_device(hass, mocker, refresh_error):
  70. """Failed runtime setup should not leave stale device state cached."""
  71. mock_device = mocker.MagicMock()
  72. if refresh_error is None:
  73. mock_device.async_refresh = mocker.AsyncMock()
  74. mock_device.has_returned_state = False
  75. else:
  76. mock_device.async_refresh = mocker.AsyncMock(side_effect=refresh_error)
  77. def fake_setup_device(hass, config):
  78. hass.data.setdefault(DOMAIN, {})
  79. hass.data[DOMAIN]["deviceid"] = {
  80. "device": mock_device,
  81. "tuyadevice": mock_device._api,
  82. "tuyadevicelock": mocker.MagicMock(),
  83. }
  84. return mock_device
  85. mocker.patch(
  86. "custom_components.tuya_local.setup_device", side_effect=fake_setup_device
  87. )
  88. entry = MockConfigEntry(
  89. domain=DOMAIN,
  90. version=13,
  91. minor_version=18,
  92. title="test",
  93. data={
  94. CONF_DEVICE_ID: "deviceid",
  95. CONF_HOST: "hostname",
  96. CONF_LOCAL_KEY: TESTKEY,
  97. CONF_POLL_ONLY: False,
  98. CONF_PROTOCOL_VERSION: 3.4,
  99. CONF_TYPE: "kogan_kahtp_heater",
  100. },
  101. options={},
  102. )
  103. with pytest.raises(ConfigEntryNotReady):
  104. await async_setup_entry(hass, entry)
  105. assert "deviceid" not in hass.data.get(DOMAIN, {})
  106. mock_device._api.set_socketPersistent.assert_called_with(False)
  107. @pytest.mark.asyncio
  108. async def test_async_unload_entry_ignores_missing_device_data(hass):
  109. """Unload should tolerate entries that failed before device data was cached."""
  110. hass.data[DOMAIN] = {}
  111. entry = MockConfigEntry(
  112. domain=DOMAIN,
  113. version=13,
  114. minor_version=18,
  115. title="test",
  116. data={
  117. CONF_DEVICE_ID: "deviceid",
  118. CONF_HOST: "hostname",
  119. CONF_LOCAL_KEY: TESTKEY,
  120. CONF_POLL_ONLY: False,
  121. CONF_PROTOCOL_VERSION: 3.4,
  122. CONF_TYPE: "kogan_kahtp_heater",
  123. },
  124. options={},
  125. )
  126. assert await async_unload_entry(hass, entry)
  127. @pytest.mark.asyncio
  128. async def test_migrate_entry(hass, mocker):
  129. """Test migration from old entry format."""
  130. mock_device = mocker.MagicMock()
  131. mock_device.async_inferred_type = mocker.AsyncMock(
  132. return_value="goldair_gpph_heater"
  133. )
  134. mocker.patch("custom_components.tuya_local.setup_device", return_value=mock_device)
  135. entry = MockConfigEntry(
  136. domain=DOMAIN,
  137. version=1,
  138. title="test",
  139. data={
  140. CONF_DEVICE_ID: "deviceid",
  141. CONF_HOST: "hostname",
  142. CONF_LOCAL_KEY: TESTKEY,
  143. CONF_TYPE: "auto",
  144. "climate": True,
  145. "child_lock": True,
  146. "display_light": True,
  147. },
  148. )
  149. entry.add_to_hass(hass)
  150. assert await async_migrate_entry(hass, entry)
  151. mock_device.async_inferred_type = mocker.AsyncMock(return_value=None)
  152. mock_device.reset_mock()
  153. entry = MockConfigEntry(
  154. domain=DOMAIN,
  155. version=1,
  156. title="test2",
  157. data={
  158. CONF_DEVICE_ID: "deviceid",
  159. CONF_HOST: "hostname",
  160. CONF_LOCAL_KEY: TESTKEY,
  161. CONF_TYPE: "unknown",
  162. "climate": False,
  163. },
  164. )
  165. entry.add_to_hass(hass)
  166. assert not await async_migrate_entry(hass, entry)
  167. mock_device.reset_mock()
  168. entry = MockConfigEntry(
  169. domain=DOMAIN,
  170. version=2,
  171. title="test3",
  172. data={
  173. CONF_DEVICE_ID: "deviceid",
  174. CONF_HOST: "hostname",
  175. CONF_LOCAL_KEY: TESTKEY,
  176. CONF_TYPE: "auto",
  177. },
  178. options={
  179. "climate": False,
  180. },
  181. )
  182. entry.add_to_hass(hass)
  183. assert not await async_migrate_entry(hass, entry)
  184. mock_device.async_inferred_type = mocker.AsyncMock(return_value="smartplugv1")
  185. mock_device.reset_mock()
  186. entry = MockConfigEntry(
  187. domain=DOMAIN,
  188. version=3,
  189. title="test4",
  190. data={
  191. CONF_DEVICE_ID: "deviceid",
  192. CONF_HOST: "hostname",
  193. CONF_LOCAL_KEY: TESTKEY,
  194. CONF_TYPE: "smartplugv1",
  195. },
  196. options={
  197. "switch": True,
  198. },
  199. )
  200. entry.add_to_hass(hass)
  201. assert await async_migrate_entry(hass, entry)
  202. mock_device.async_inferred_type = mocker.AsyncMock(return_value="smartplugv2")
  203. mock_device.reset_mock()
  204. entry = MockConfigEntry(
  205. domain=DOMAIN,
  206. version=3,
  207. title="test5",
  208. data={
  209. CONF_DEVICE_ID: "deviceid",
  210. CONF_HOST: "hostname",
  211. CONF_LOCAL_KEY: TESTKEY,
  212. CONF_TYPE: "smartplugv1",
  213. },
  214. options={
  215. "switch": True,
  216. },
  217. )
  218. entry.add_to_hass(hass)
  219. assert await async_migrate_entry(hass, entry)
  220. mock_device.async_inferred_type = mocker.AsyncMock(
  221. return_value="goldair_dehumidifier"
  222. )
  223. mock_device.reset_mock()
  224. entry = MockConfigEntry(
  225. domain=DOMAIN,
  226. version=4,
  227. title="test6",
  228. data={
  229. CONF_DEVICE_ID: "deviceid",
  230. CONF_HOST: "hostname",
  231. CONF_LOCAL_KEY: TESTKEY,
  232. CONF_TYPE: "goldair_dehumidifier",
  233. },
  234. options={
  235. "humidifier": True,
  236. "fan": True,
  237. "light": True,
  238. "lock": False,
  239. "switch": True,
  240. },
  241. )
  242. entry.add_to_hass(hass)
  243. assert await async_migrate_entry(hass, entry)
  244. mock_device.async_inferred_type = mocker.AsyncMock(
  245. return_value="grid_connect_usb_double_power_point"
  246. )
  247. mock_device.reset_mock()
  248. entry = MockConfigEntry(
  249. domain=DOMAIN,
  250. version=6,
  251. title="test7",
  252. data={
  253. CONF_DEVICE_ID: "deviceid",
  254. CONF_HOST: "hostname",
  255. CONF_LOCAL_KEY: TESTKEY,
  256. CONF_TYPE: "grid_connect_usb_double_power_point",
  257. },
  258. options={
  259. "switch_main_switch": True,
  260. "switch_left_outlet": True,
  261. "switch_right_outlet": True,
  262. },
  263. )
  264. entry.add_to_hass(hass)
  265. assert await async_migrate_entry(hass, entry)
  266. @pytest.mark.asyncio
  267. async def test_flow_user_init(hass, mocker):
  268. """Test the initialisation of the form in the first page of the manual config flow path."""
  269. result = await hass.config_entries.flow.async_init(
  270. DOMAIN, context={"source": "local"}
  271. )
  272. expected = {
  273. "data_schema": mocker.ANY,
  274. "description_placeholders": mocker.ANY,
  275. "errors": {},
  276. "flow_id": mocker.ANY,
  277. "handler": DOMAIN,
  278. "step_id": "local",
  279. "type": "form",
  280. "last_step": mocker.ANY,
  281. "preview": mocker.ANY,
  282. }
  283. assert expected == result
  284. # Check the schema. Simple comparison does not work since they are not
  285. # the same object
  286. try:
  287. result["data_schema"](
  288. {CONF_DEVICE_ID: "test", CONF_LOCAL_KEY: TESTKEY, CONF_HOST: "test"}
  289. )
  290. except vol.MultipleInvalid:
  291. assert False
  292. try:
  293. result["data_schema"]({CONF_DEVICE_ID: "missing_some"})
  294. assert False
  295. except vol.MultipleInvalid:
  296. pass
  297. @pytest.mark.asyncio
  298. async def test_flow_user_init_protocol_options_are_strings(hass, mocker):
  299. """Test that protocol version dropdown uses strings, not floats."""
  300. result = await hass.config_entries.flow.async_init(
  301. DOMAIN, context={"source": "local"}
  302. )
  303. schema = result["data_schema"]
  304. # Validate that string protocol versions are accepted
  305. schema(
  306. {
  307. CONF_DEVICE_ID: "test",
  308. CONF_LOCAL_KEY: TESTKEY,
  309. CONF_HOST: "test",
  310. CONF_PROTOCOL_VERSION: "3.3",
  311. CONF_POLL_ONLY: False,
  312. }
  313. )
  314. # Validate that float protocol versions are rejected
  315. with pytest.raises(vol.MultipleInvalid):
  316. schema(
  317. {
  318. CONF_DEVICE_ID: "test",
  319. CONF_LOCAL_KEY: TESTKEY,
  320. CONF_HOST: "test",
  321. CONF_PROTOCOL_VERSION: 3.3,
  322. CONF_POLL_ONLY: False,
  323. }
  324. )
  325. @pytest.mark.asyncio
  326. async def test_async_test_connection_valid(hass, mocker):
  327. """Test that device is returned when connection is valid."""
  328. mock_device = mocker.patch(
  329. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  330. )
  331. mock_instance = mocker.AsyncMock()
  332. mock_instance.has_returned_state = True
  333. mock_instance.pause = mocker.MagicMock()
  334. mock_instance.resume = mocker.MagicMock()
  335. mock_device.return_value = mock_instance
  336. hass.data[DOMAIN] = {"deviceid": {"device": mock_instance}}
  337. device = await config_flow.async_test_connection(
  338. {
  339. CONF_DEVICE_ID: "deviceid",
  340. CONF_LOCAL_KEY: TESTKEY,
  341. CONF_HOST: "hostname",
  342. CONF_PROTOCOL_VERSION: "auto",
  343. },
  344. hass,
  345. )
  346. assert device == mock_instance
  347. mock_instance.pause.assert_called_once()
  348. mock_instance.resume.assert_called_once()
  349. @pytest.mark.asyncio
  350. async def test_async_test_connection_for_subdevice_valid(hass, mocker):
  351. """Test that subdevice is returned when connection is valid."""
  352. mock_device = mocker.patch(
  353. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  354. )
  355. mock_instance = mocker.AsyncMock()
  356. mock_instance.has_returned_state = True
  357. mock_instance.pause = mocker.MagicMock()
  358. mock_instance.resume = mocker.MagicMock()
  359. mock_device.return_value = mock_instance
  360. hass.data[DOMAIN] = {"subdeviceid": {"device": mock_instance}}
  361. device = await config_flow.async_test_connection(
  362. {
  363. CONF_DEVICE_ID: "deviceid",
  364. CONF_LOCAL_KEY: TESTKEY,
  365. CONF_HOST: "hostname",
  366. CONF_PROTOCOL_VERSION: "auto",
  367. CONF_DEVICE_CID: "subdeviceid",
  368. },
  369. hass,
  370. )
  371. assert device == mock_instance
  372. mock_instance.pause.assert_called_once()
  373. mock_instance.resume.assert_called_once()
  374. @pytest.mark.asyncio
  375. async def test_async_test_connection_invalid(hass, mocker):
  376. """Test that None is returned when connection is invalid."""
  377. mock_device = mocker.patch(
  378. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  379. )
  380. mock_instance = mocker.AsyncMock()
  381. mock_instance.has_returned_state = False
  382. mock_instance._api = mocker.MagicMock()
  383. mock_device.return_value = mock_instance
  384. device = await config_flow.async_test_connection(
  385. {
  386. CONF_DEVICE_ID: "deviceid",
  387. CONF_LOCAL_KEY: TESTKEY,
  388. CONF_HOST: "hostname",
  389. CONF_PROTOCOL_VERSION: "auto",
  390. },
  391. hass,
  392. )
  393. assert device is None
  394. @pytest.mark.asyncio
  395. async def test_flow_user_init_invalid_config(hass, mocker):
  396. """Test errors populated when config is invalid."""
  397. mocker.patch(
  398. "custom_components.tuya_local.config_flow.async_test_connection",
  399. return_value=None,
  400. )
  401. flow = await hass.config_entries.flow.async_init(
  402. DOMAIN, context={"source": "local"}
  403. )
  404. result = await hass.config_entries.flow.async_configure(
  405. flow["flow_id"],
  406. user_input={
  407. CONF_DEVICE_ID: "deviceid",
  408. CONF_HOST: "hostname",
  409. CONF_LOCAL_KEY: "badkey",
  410. CONF_PROTOCOL_VERSION: "auto",
  411. CONF_POLL_ONLY: False,
  412. },
  413. )
  414. assert {"base": "connection"} == result["errors"]
  415. def setup_device_mock(mock, mocker, failure=False, devtype="test"):
  416. mock_type = mocker.MagicMock()
  417. mock_type.legacy_type = devtype
  418. mock_type.config_type = devtype
  419. mock_type.match_quality.return_value = 100
  420. mock_type.product_display_entries.return_value = [(None, None)]
  421. mock.async_possible_types = mocker.AsyncMock(
  422. return_value=[mock_type] if not failure else []
  423. )
  424. @pytest.mark.asyncio
  425. async def test_flow_user_init_data_valid(hass, mocker):
  426. """Test we advance to the next step when connection config is valid."""
  427. mock_device = mocker.MagicMock()
  428. mock_device._protocol_configured = "auto"
  429. setup_device_mock(mock_device, mocker)
  430. mocker.patch(
  431. "custom_components.tuya_local.config_flow.async_test_connection",
  432. return_value=mock_device,
  433. )
  434. flow = await hass.config_entries.flow.async_init(
  435. DOMAIN, context={"source": "local"}
  436. )
  437. result = await hass.config_entries.flow.async_configure(
  438. flow["flow_id"],
  439. user_input={
  440. CONF_DEVICE_ID: "deviceid",
  441. CONF_HOST: "hostname",
  442. CONF_LOCAL_KEY: TESTKEY,
  443. },
  444. )
  445. assert "form" == result["type"]
  446. assert "select_type" == result["step_id"]
  447. @pytest.mark.asyncio
  448. async def test_flow_select_type_init(hass, mocker):
  449. """Test the initialisation of the form in the 2nd step of the config flow."""
  450. mock_device = mocker.patch.object(config_flow.ConfigFlowHandler, "device")
  451. setup_device_mock(mock_device, mocker)
  452. result = await hass.config_entries.flow.async_init(
  453. DOMAIN, context={"source": "select_type"}
  454. )
  455. expected = {
  456. "data_schema": mocker.ANY,
  457. "description_placeholders": {"device_name": ""},
  458. "errors": None,
  459. "flow_id": mocker.ANY,
  460. "handler": DOMAIN,
  461. "step_id": "select_type",
  462. "type": "form",
  463. "last_step": mocker.ANY,
  464. "preview": mocker.ANY,
  465. }
  466. assert expected == result
  467. # Check the schema. Simple comparison does not work since they are not
  468. # the same object
  469. try:
  470. result["data_schema"]({CONF_TYPE: "test||||"})
  471. except vol.MultipleInvalid:
  472. assert False
  473. try:
  474. result["data_schema"]({CONF_TYPE: "not_test||||"})
  475. assert False
  476. except vol.MultipleInvalid:
  477. pass
  478. @pytest.mark.asyncio
  479. async def test_flow_select_type_aborts_when_no_match(hass, mocker):
  480. """Test the flow aborts when an unsupported device is used."""
  481. mock_device = mocker.patch.object(config_flow.ConfigFlowHandler, "device")
  482. setup_device_mock(mock_device, mocker, failure=True)
  483. result = await hass.config_entries.flow.async_init(
  484. DOMAIN, context={"source": "select_type"}
  485. )
  486. assert result["type"] == "abort"
  487. assert result["reason"] == "not_supported"
  488. @pytest.mark.asyncio
  489. async def test_flow_select_type_data_valid(hass, mocker):
  490. """Test the flow continues when valid data is supplied."""
  491. mock_device = mocker.patch.object(config_flow.ConfigFlowHandler, "device")
  492. setup_device_mock(mock_device, mocker, devtype="smartplugv1")
  493. flow = await hass.config_entries.flow.async_init(
  494. DOMAIN, context={"source": "select_type"}
  495. )
  496. result = await hass.config_entries.flow.async_configure(
  497. flow["flow_id"],
  498. user_input={CONF_TYPE: "smartplugv1||||"},
  499. )
  500. assert "form" == result["type"]
  501. assert "choose_entities" == result["step_id"]
  502. @pytest.mark.asyncio
  503. async def test_flow_choose_entities_init(hass, mocker):
  504. """Test the initialisation of the form in the 3rd step of the config flow."""
  505. mocker.patch.dict(config_flow.ConfigFlowHandler.data, {CONF_TYPE: "smartplugv1"})
  506. result = await hass.config_entries.flow.async_init(
  507. DOMAIN, context={"source": "choose_entities"}
  508. )
  509. expected = {
  510. "data_schema": mocker.ANY,
  511. "description_placeholders": {"device_name": ""},
  512. "errors": None,
  513. "flow_id": mocker.ANY,
  514. "handler": DOMAIN,
  515. "step_id": "choose_entities",
  516. "type": "form",
  517. "last_step": mocker.ANY,
  518. "preview": mocker.ANY,
  519. }
  520. assert expected == result
  521. # Check the schema. Simple comparison does not work since they are not
  522. # the same object
  523. try:
  524. result["data_schema"]({CONF_NAME: "test"})
  525. except vol.MultipleInvalid:
  526. assert False
  527. try:
  528. result["data_schema"]({"climate": True})
  529. assert False
  530. except vol.MultipleInvalid:
  531. pass
  532. @pytest.mark.asyncio
  533. async def test_flow_choose_entities_creates_config_entry(hass, bypass_setup, mocker):
  534. """Test the flow ends when data is valid."""
  535. mocker.patch.dict(
  536. config_flow.ConfigFlowHandler.data,
  537. {
  538. CONF_DEVICE_ID: "deviceid",
  539. CONF_LOCAL_KEY: TESTKEY,
  540. CONF_HOST: "hostname",
  541. CONF_POLL_ONLY: False,
  542. CONF_PROTOCOL_VERSION: "auto",
  543. CONF_TYPE: "kogan_kahtp_heater",
  544. CONF_DEVICE_CID: None,
  545. },
  546. )
  547. flow = await hass.config_entries.flow.async_init(
  548. DOMAIN, context={"source": "choose_entities"}
  549. )
  550. result = await hass.config_entries.flow.async_configure(
  551. flow["flow_id"],
  552. user_input={
  553. CONF_NAME: "test",
  554. },
  555. )
  556. expected = {
  557. "version": 13,
  558. "minor_version": mocker.ANY,
  559. "context": {"source": "choose_entities"},
  560. "type": FlowResultType.CREATE_ENTRY,
  561. "flow_id": mocker.ANY,
  562. "handler": DOMAIN,
  563. "title": "test",
  564. "description": None,
  565. "description_placeholders": None,
  566. "result": mocker.ANY,
  567. "subentries": (),
  568. "options": {},
  569. "data": {
  570. CONF_DEVICE_ID: "deviceid",
  571. CONF_HOST: "hostname",
  572. CONF_LOCAL_KEY: TESTKEY,
  573. CONF_POLL_ONLY: False,
  574. CONF_PROTOCOL_VERSION: "auto",
  575. CONF_TYPE: "kogan_kahtp_heater",
  576. CONF_DEVICE_CID: None,
  577. },
  578. }
  579. assert expected == result
  580. @pytest.mark.asyncio
  581. async def test_options_flow_init(hass, bypass_data_fetch):
  582. """Test config flow options."""
  583. config_entry = MockConfigEntry(
  584. domain=DOMAIN,
  585. version=13,
  586. unique_id="uniqueid",
  587. data={
  588. CONF_DEVICE_ID: "deviceid",
  589. CONF_HOST: "hostname",
  590. CONF_LOCAL_KEY: TESTKEY,
  591. CONF_NAME: "test",
  592. CONF_POLL_ONLY: False,
  593. CONF_PROTOCOL_VERSION: "auto",
  594. CONF_TYPE: "smartplugv1",
  595. CONF_DEVICE_CID: "",
  596. },
  597. )
  598. config_entry.add_to_hass(hass)
  599. assert await hass.config_entries.async_setup(config_entry.entry_id)
  600. await hass.async_block_till_done()
  601. # show initial form
  602. result = await hass.config_entries.options.async_init(config_entry.entry_id)
  603. assert "form" == result["type"]
  604. assert "user" == result["step_id"]
  605. assert {} == result["errors"]
  606. assert result["data_schema"](
  607. {
  608. CONF_HOST: "hostname",
  609. CONF_LOCAL_KEY: TESTKEY,
  610. }
  611. )
  612. @pytest.mark.asyncio
  613. async def test_options_flow_modifies_config(hass, bypass_setup, mocker):
  614. mock_device = mocker.MagicMock()
  615. mocker.patch(
  616. "custom_components.tuya_local.config_flow.async_test_connection",
  617. return_value=mock_device,
  618. )
  619. config_entry = MockConfigEntry(
  620. domain=DOMAIN,
  621. version=13,
  622. unique_id="uniqueid",
  623. data={
  624. CONF_DEVICE_ID: "deviceid",
  625. CONF_HOST: "hostname",
  626. CONF_LOCAL_KEY: TESTKEY,
  627. CONF_NAME: "test",
  628. CONF_POLL_ONLY: False,
  629. CONF_PROTOCOL_VERSION: "auto",
  630. CONF_TYPE: "ble_pt216_temp_humidity",
  631. CONF_DEVICE_CID: "subdeviceid",
  632. },
  633. )
  634. config_entry.add_to_hass(hass)
  635. assert await hass.config_entries.async_setup(config_entry.entry_id)
  636. await hass.async_block_till_done()
  637. # show initial form
  638. form = await hass.config_entries.options.async_init(config_entry.entry_id)
  639. # submit updated config
  640. result = await hass.config_entries.options.async_configure(
  641. form["flow_id"],
  642. user_input={
  643. CONF_HOST: "new_hostname",
  644. CONF_LOCAL_KEY: "new_key",
  645. CONF_POLL_ONLY: False,
  646. CONF_PROTOCOL_VERSION: "3.3",
  647. },
  648. )
  649. expected = {
  650. CONF_HOST: "new_hostname",
  651. CONF_LOCAL_KEY: "new_key",
  652. CONF_POLL_ONLY: False,
  653. CONF_PROTOCOL_VERSION: 3.3,
  654. }
  655. assert "create_entry" == result["type"]
  656. assert "" == result["title"]
  657. assert expected == result["data"]
  658. @pytest.mark.asyncio
  659. async def test_options_flow_fails_when_connection_fails(
  660. hass, bypass_data_fetch, mocker
  661. ):
  662. mocker.patch(
  663. "custom_components.tuya_local.config_flow.async_test_connection",
  664. return_value=None,
  665. )
  666. config_entry = MockConfigEntry(
  667. domain=DOMAIN,
  668. version=13,
  669. unique_id="uniqueid",
  670. data={
  671. CONF_DEVICE_ID: "deviceid",
  672. CONF_HOST: "hostname",
  673. CONF_LOCAL_KEY: TESTKEY,
  674. CONF_NAME: "test",
  675. CONF_POLL_ONLY: False,
  676. CONF_PROTOCOL_VERSION: "auto",
  677. CONF_TYPE: "smartplugv1",
  678. CONF_DEVICE_CID: "",
  679. },
  680. )
  681. config_entry.add_to_hass(hass)
  682. assert await hass.config_entries.async_setup(config_entry.entry_id)
  683. await hass.async_block_till_done()
  684. # show initial form
  685. form = await hass.config_entries.options.async_init(config_entry.entry_id)
  686. # submit updated config
  687. result = await hass.config_entries.options.async_configure(
  688. form["flow_id"],
  689. user_input={
  690. CONF_HOST: "new_hostname",
  691. CONF_LOCAL_KEY: "new_key",
  692. },
  693. )
  694. assert "form" == result["type"]
  695. assert "user" == result["step_id"]
  696. assert {"base": "connection"} == result["errors"]
  697. @pytest.mark.asyncio
  698. async def test_options_flow_fails_when_config_is_missing(hass, mocker):
  699. mock_device = mocker.MagicMock()
  700. mocker.patch(
  701. "custom_components.tuya_local.config_flow.async_test_connection",
  702. return_value=mock_device,
  703. )
  704. config_entry = MockConfigEntry(
  705. domain=DOMAIN,
  706. version=13,
  707. unique_id="uniqueid",
  708. data={
  709. CONF_DEVICE_ID: "deviceid",
  710. CONF_HOST: "hostname",
  711. CONF_LOCAL_KEY: TESTKEY,
  712. CONF_NAME: "test",
  713. CONF_POLL_ONLY: False,
  714. CONF_PROTOCOL_VERSION: "auto",
  715. CONF_TYPE: "non_existing",
  716. },
  717. )
  718. config_entry.add_to_hass(hass)
  719. await hass.config_entries.async_setup(config_entry.entry_id)
  720. await hass.async_block_till_done()
  721. # show initial form
  722. result = await hass.config_entries.options.async_init(config_entry.entry_id)
  723. assert result["type"] == "abort"
  724. assert result["reason"] == "not_supported"
  725. def test_migration_gets_correct_device_id():
  726. """Test that migration gets the correct device id."""
  727. # Normal device
  728. entry = MockConfigEntry(
  729. domain=DOMAIN,
  730. version=1,
  731. title="test",
  732. data={
  733. CONF_DEVICE_ID: "deviceid",
  734. CONF_HOST: "hostname",
  735. CONF_LOCAL_KEY: TESTKEY,
  736. CONF_TYPE: "auto",
  737. },
  738. )
  739. assert get_device_unique_id(entry) == "deviceid"
  740. # ---------------------------------------------------------------------------
  741. # async_step_user
  742. # ---------------------------------------------------------------------------
  743. @pytest.mark.asyncio
  744. async def test_flow_user_shows_form(hass):
  745. """Test the user step shows the setup mode form when no input."""
  746. result = await hass.config_entries.flow.async_init(
  747. DOMAIN, context={"source": "user"}
  748. )
  749. assert result["type"] == "form"
  750. assert result["step_id"] == "user"
  751. @pytest.mark.asyncio
  752. async def test_flow_user_manual_goes_to_local(hass):
  753. """Test that choosing 'manual' advances to the local step."""
  754. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  755. result = await hass.config_entries.flow.async_configure(
  756. flow["flow_id"], user_input={"setup_mode": "manual"}
  757. )
  758. assert result["type"] == "form"
  759. assert result["step_id"] == "local"
  760. @pytest.mark.asyncio
  761. async def test_flow_user_cloud_authenticated_goes_to_choose_device(hass, mocker):
  762. """Test cloud mode when already authenticated goes to choose_device."""
  763. mock_cloud = mocker.MagicMock()
  764. mock_cloud.is_authenticated = True
  765. mock_cloud.async_get_devices = AsyncMock(
  766. return_value={
  767. "dev1": {
  768. "name": "Light",
  769. "product_name": "Smart Light",
  770. "local_key": "key1",
  771. "online": True,
  772. "is_hub": False,
  773. "exists": False,
  774. }
  775. }
  776. )
  777. mocker.patch(
  778. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  779. )
  780. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  781. result = await hass.config_entries.flow.async_configure(
  782. flow["flow_id"], user_input={"setup_mode": "cloud"}
  783. )
  784. assert result["type"] == "form"
  785. assert result["step_id"] == "choose_device"
  786. @pytest.mark.asyncio
  787. async def test_flow_user_cloud_not_authenticated_goes_to_cloud_step(hass, mocker):
  788. """Test cloud mode when not authenticated goes to the cloud (QR) step."""
  789. mock_cloud = mocker.MagicMock()
  790. mock_cloud.is_authenticated = False
  791. mocker.patch(
  792. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  793. )
  794. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  795. result = await hass.config_entries.flow.async_configure(
  796. flow["flow_id"], user_input={"setup_mode": "cloud"}
  797. )
  798. assert result["type"] == "form"
  799. assert result["step_id"] == "cloud"
  800. @pytest.mark.asyncio
  801. async def test_flow_user_cloud_fresh_login_logs_out_and_goes_to_cloud(hass, mocker):
  802. """Test cloud_fresh_login forces logout then goes to cloud step."""
  803. mock_cloud = mocker.MagicMock()
  804. mock_cloud.is_authenticated = False
  805. mock_cloud.logout = mocker.MagicMock()
  806. mocker.patch(
  807. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  808. )
  809. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  810. result = await hass.config_entries.flow.async_configure(
  811. flow["flow_id"], user_input={"setup_mode": "cloud_fresh_login"}
  812. )
  813. mock_cloud.logout.assert_called_once()
  814. assert result["type"] == "form"
  815. assert result["step_id"] == "cloud"
  816. @pytest.mark.asyncio
  817. async def test_flow_user_cloud_exception_goes_to_cloud_step(hass, mocker):
  818. """Test that cloud exceptions cause re-auth (go to cloud step)."""
  819. mock_cloud = mocker.MagicMock()
  820. mock_cloud.is_authenticated = True
  821. mock_cloud.async_get_devices = AsyncMock(side_effect=Exception("network error"))
  822. mocker.patch(
  823. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  824. )
  825. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  826. result = await hass.config_entries.flow.async_configure(
  827. flow["flow_id"], user_input={"setup_mode": "cloud"}
  828. )
  829. assert result["type"] == "form"
  830. assert result["step_id"] == "cloud"
  831. # ---------------------------------------------------------------------------
  832. # async_step_cloud
  833. # ---------------------------------------------------------------------------
  834. @pytest.mark.asyncio
  835. async def test_flow_cloud_shows_form(hass, mocker):
  836. """Test cloud step shows the user_code form."""
  837. mocker.patch("custom_components.tuya_local.config_flow.Cloud")
  838. result = await hass.config_entries.flow.async_init(
  839. DOMAIN, context={"source": "cloud"}
  840. )
  841. assert result["type"] == "form"
  842. assert result["step_id"] == "cloud"
  843. @pytest.mark.asyncio
  844. async def test_flow_cloud_success_goes_to_scan(hass, mocker):
  845. """Test entering a user code that succeeds goes to QR scan step."""
  846. mock_cloud = mocker.MagicMock()
  847. mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN_123")
  848. mocker.patch(
  849. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  850. )
  851. flow = await hass.config_entries.flow.async_init(
  852. DOMAIN, context={"source": "cloud"}
  853. )
  854. result = await hass.config_entries.flow.async_configure(
  855. flow["flow_id"], user_input={"user_code": "MY_CODE"}
  856. )
  857. assert result["type"] == "form"
  858. assert result["step_id"] == "scan"
  859. @pytest.mark.asyncio
  860. async def test_flow_cloud_failure_shows_error(hass, mocker):
  861. """Test entering a bad user code stays on cloud step with error."""
  862. mock_cloud = mocker.MagicMock()
  863. mock_cloud.async_get_qr_code = AsyncMock(return_value=False)
  864. mock_cloud.last_error = {"msg": "Invalid code", "code": 1001}
  865. mocker.patch(
  866. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  867. )
  868. flow = await hass.config_entries.flow.async_init(
  869. DOMAIN, context={"source": "cloud"}
  870. )
  871. result = await hass.config_entries.flow.async_configure(
  872. flow["flow_id"], user_input={"user_code": "BAD_CODE"}
  873. )
  874. assert result["type"] == "form"
  875. assert result["step_id"] == "cloud"
  876. assert result["errors"] == {"base": "login_error"}
  877. # ---------------------------------------------------------------------------
  878. # async_step_scan
  879. # ---------------------------------------------------------------------------
  880. @pytest.mark.asyncio
  881. async def test_flow_scan_shows_qr_form(hass, mocker):
  882. """Test the scan step shows the QR code form."""
  883. mock_cloud = mocker.MagicMock()
  884. mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN")
  885. mocker.patch(
  886. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  887. )
  888. # Get to scan via cloud step
  889. flow = await hass.config_entries.flow.async_init(
  890. DOMAIN, context={"source": "cloud"}
  891. )
  892. result = await hass.config_entries.flow.async_configure(
  893. flow["flow_id"], user_input={"user_code": "CODE"}
  894. )
  895. assert result["step_id"] == "scan"
  896. @pytest.mark.asyncio
  897. async def test_flow_scan_login_success_goes_to_choose_device(hass, mocker):
  898. """Test scanning QR and successful login goes to choose_device."""
  899. mock_cloud = mocker.MagicMock()
  900. mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN")
  901. mock_cloud.async_login = AsyncMock(return_value=True)
  902. mock_cloud.async_get_devices = AsyncMock(
  903. return_value={
  904. "dev1": {
  905. "name": "Plug",
  906. "product_name": "Smart Plug",
  907. "local_key": "key",
  908. "online": True,
  909. "is_hub": False,
  910. "exists": False,
  911. }
  912. }
  913. )
  914. mocker.patch(
  915. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  916. )
  917. flow = await hass.config_entries.flow.async_init(
  918. DOMAIN, context={"source": "cloud"}
  919. )
  920. await hass.config_entries.flow.async_configure(
  921. flow["flow_id"], user_input={"user_code": "CODE"}
  922. )
  923. result = await hass.config_entries.flow.async_configure(
  924. flow["flow_id"], user_input={}
  925. )
  926. assert result["type"] == "form"
  927. assert result["step_id"] == "choose_device"
  928. @pytest.mark.asyncio
  929. async def test_flow_scan_login_failure_stays_on_scan(hass, mocker):
  930. """Test failed login stays on scan step with error."""
  931. mock_cloud = mocker.MagicMock()
  932. mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN")
  933. mock_cloud.async_login = AsyncMock(return_value=False)
  934. mock_cloud.last_error = {"msg": "Auth failed", "code": 2000}
  935. mocker.patch(
  936. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  937. )
  938. flow = await hass.config_entries.flow.async_init(
  939. DOMAIN, context={"source": "cloud"}
  940. )
  941. await hass.config_entries.flow.async_configure(
  942. flow["flow_id"], user_input={"user_code": "CODE"}
  943. )
  944. result = await hass.config_entries.flow.async_configure(
  945. flow["flow_id"], user_input={}
  946. )
  947. assert result["type"] == "form"
  948. assert result["step_id"] == "scan"
  949. assert result["errors"] == {"base": "login_error"}
  950. # ---------------------------------------------------------------------------
  951. # async_step_choose_device
  952. # ---------------------------------------------------------------------------
  953. def _make_cloud_devices(include_hub=False, include_offline=False):
  954. devices = {
  955. "dev1": {
  956. "name": "Smart Light",
  957. "product_name": "Light",
  958. "local_key": "key1",
  959. "online": True,
  960. "is_hub": False,
  961. "exists": False,
  962. "ip": "192.168.1.10",
  963. }
  964. }
  965. if include_hub:
  966. devices["hub1"] = {
  967. "name": "Zigbee Hub",
  968. "product_name": "Hub",
  969. "local_key": "hubkey",
  970. "online": True,
  971. "is_hub": True,
  972. "exists": False,
  973. "ip": "192.168.1.1",
  974. }
  975. if include_offline:
  976. devices["dev2"] = {
  977. "name": "Offline Device",
  978. "product_name": "Sensor",
  979. "local_key": "key2",
  980. "online": False,
  981. "is_hub": False,
  982. "exists": False,
  983. "ip": "192.168.1.20",
  984. }
  985. return devices
  986. @pytest.mark.asyncio
  987. async def test_flow_choose_device_shows_form(hass, mocker):
  988. """Test the choose_device step shows the device list form."""
  989. mock_cloud = mocker.MagicMock()
  990. mock_cloud.is_authenticated = True
  991. mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
  992. mocker.patch(
  993. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  994. )
  995. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  996. result = await hass.config_entries.flow.async_configure(
  997. flow["flow_id"], user_input={"setup_mode": "cloud"}
  998. )
  999. assert result["type"] == "form"
  1000. assert result["step_id"] == "choose_device"
  1001. @pytest.mark.asyncio
  1002. async def test_flow_choose_device_aborts_when_no_devices(hass, mocker):
  1003. """Test choose_device aborts when no new devices are available."""
  1004. mock_cloud = mocker.MagicMock()
  1005. mock_cloud.is_authenticated = True
  1006. mock_cloud.async_get_devices = AsyncMock(return_value={})
  1007. mocker.patch(
  1008. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1009. )
  1010. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1011. result = await hass.config_entries.flow.async_configure(
  1012. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1013. )
  1014. assert result["type"] == "abort"
  1015. assert result["reason"] == "no_devices"
  1016. @pytest.mark.asyncio
  1017. async def test_flow_choose_device_direct_device_no_hub_goes_to_search(hass, mocker):
  1018. """Test selecting a directly addressable device (no hub) goes to search."""
  1019. devices = _make_cloud_devices()
  1020. mock_cloud = mocker.MagicMock()
  1021. mock_cloud.is_authenticated = True
  1022. mock_cloud.async_get_devices = AsyncMock(return_value=devices)
  1023. mocker.patch(
  1024. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1025. )
  1026. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1027. await hass.config_entries.flow.async_configure(
  1028. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1029. )
  1030. result = await hass.config_entries.flow.async_configure(
  1031. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1032. )
  1033. assert result["type"] == "form"
  1034. assert result["step_id"] == "search"
  1035. @pytest.mark.asyncio
  1036. async def test_flow_choose_device_direct_device_with_hub_shows_error(hass, mocker):
  1037. """Test selecting a hub for a direct device shows an error."""
  1038. devices = _make_cloud_devices(include_hub=True)
  1039. mock_cloud = mocker.MagicMock()
  1040. mock_cloud.is_authenticated = True
  1041. mock_cloud.async_get_devices = AsyncMock(return_value=devices)
  1042. mocker.patch(
  1043. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1044. )
  1045. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1046. await hass.config_entries.flow.async_configure(
  1047. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1048. )
  1049. result = await hass.config_entries.flow.async_configure(
  1050. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "hub1"}
  1051. )
  1052. assert result["type"] == "form"
  1053. assert result["step_id"] == "choose_device"
  1054. assert result["errors"] == {"base": "does_not_need_hub"}
  1055. @pytest.mark.asyncio
  1056. async def test_flow_choose_device_indirect_device_with_hub_goes_to_search(hass, mocker):
  1057. """Test selecting an indirect device with a hub goes to search."""
  1058. devices = {
  1059. "subdev1": {
  1060. "name": "Sub Device",
  1061. "product_name": "Sensor",
  1062. "local_key": "subkey", # non-empty so it appears in list
  1063. "online": True,
  1064. "is_hub": False,
  1065. "exists": False,
  1066. "ip": "", # empty ip = indirect/sub-device
  1067. "node_id": "node123",
  1068. "uuid": "uuid123",
  1069. "product_id": "prod_sub",
  1070. },
  1071. "hub1": {
  1072. "name": "Hub",
  1073. "product_name": "Gateway",
  1074. "local_key": "hubkey",
  1075. "online": True,
  1076. "is_hub": True,
  1077. "exists": False,
  1078. "ip": "192.168.1.1",
  1079. },
  1080. }
  1081. mock_cloud = mocker.MagicMock()
  1082. mock_cloud.is_authenticated = True
  1083. mock_cloud.async_get_devices = AsyncMock(return_value=devices)
  1084. mocker.patch(
  1085. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1086. )
  1087. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1088. await hass.config_entries.flow.async_configure(
  1089. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1090. )
  1091. result = await hass.config_entries.flow.async_configure(
  1092. flow["flow_id"], user_input={"device_id": "subdev1", "hub_id": "hub1"}
  1093. )
  1094. assert result["type"] == "form"
  1095. assert result["step_id"] == "search"
  1096. @pytest.mark.asyncio
  1097. async def test_flow_choose_device_indirect_no_hub_shows_error(hass, mocker):
  1098. """Test selecting an indirect device without a hub shows an error."""
  1099. devices = {
  1100. "subdev1": {
  1101. "name": "Sub Device",
  1102. "product_name": "Sensor",
  1103. "local_key": "subkey", # non-empty so it appears in list
  1104. "online": True,
  1105. "is_hub": False,
  1106. "exists": False,
  1107. "ip": "", # empty ip = indirect/sub-device
  1108. }
  1109. }
  1110. mock_cloud = mocker.MagicMock()
  1111. mock_cloud.is_authenticated = True
  1112. mock_cloud.async_get_devices = AsyncMock(return_value=devices)
  1113. mocker.patch(
  1114. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1115. )
  1116. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1117. await hass.config_entries.flow.async_configure(
  1118. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1119. )
  1120. result = await hass.config_entries.flow.async_configure(
  1121. flow["flow_id"], user_input={"device_id": "subdev1", "hub_id": "None"}
  1122. )
  1123. assert result["type"] == "form"
  1124. assert result["step_id"] == "choose_device"
  1125. assert result["errors"] == {"base": "needs_hub"}
  1126. # ---------------------------------------------------------------------------
  1127. # async_step_search
  1128. # ---------------------------------------------------------------------------
  1129. @pytest.mark.asyncio
  1130. async def test_flow_search_shows_form(hass, mocker):
  1131. """Test the search step shows the scanning form."""
  1132. mock_cloud = mocker.MagicMock()
  1133. mock_cloud.is_authenticated = True
  1134. mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
  1135. mocker.patch(
  1136. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1137. )
  1138. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1139. await hass.config_entries.flow.async_configure(
  1140. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1141. )
  1142. result = await hass.config_entries.flow.async_configure(
  1143. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1144. )
  1145. assert result["type"] == "form"
  1146. assert result["step_id"] == "search"
  1147. @pytest.mark.asyncio
  1148. async def test_flow_search_found_device_goes_to_local(hass, mocker):
  1149. """Test that finding a device on the network advances to the local step."""
  1150. mock_cloud = mocker.MagicMock()
  1151. mock_cloud.is_authenticated = True
  1152. mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
  1153. mocker.patch(
  1154. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1155. )
  1156. mocker.patch(
  1157. "custom_components.tuya_local.config_flow.scan_for_device",
  1158. return_value={"ip": "192.168.1.50", "version": "3.3", "productKey": "pk123"},
  1159. )
  1160. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1161. await hass.config_entries.flow.async_configure(
  1162. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1163. )
  1164. await hass.config_entries.flow.async_configure(
  1165. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1166. )
  1167. result = await hass.config_entries.flow.async_configure(
  1168. flow["flow_id"], user_input={}
  1169. )
  1170. assert result["type"] == "form"
  1171. assert result["step_id"] == "local"
  1172. @pytest.mark.asyncio
  1173. async def test_flow_search_not_found_still_goes_to_local(hass, mocker):
  1174. """Test that not finding a device still advances to local step (blank IP)."""
  1175. mock_cloud = mocker.MagicMock()
  1176. mock_cloud.is_authenticated = True
  1177. mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
  1178. mocker.patch(
  1179. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1180. )
  1181. mocker.patch(
  1182. "custom_components.tuya_local.config_flow.scan_for_device",
  1183. return_value={"ip": None},
  1184. )
  1185. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1186. await hass.config_entries.flow.async_configure(
  1187. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1188. )
  1189. await hass.config_entries.flow.async_configure(
  1190. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1191. )
  1192. result = await hass.config_entries.flow.async_configure(
  1193. flow["flow_id"], user_input={}
  1194. )
  1195. assert result["type"] == "form"
  1196. assert result["step_id"] == "local"
  1197. @pytest.mark.asyncio
  1198. async def test_flow_search_oserror_still_goes_to_local(hass, mocker):
  1199. """Test that an OSError during scan still advances to local step."""
  1200. mock_cloud = mocker.MagicMock()
  1201. mock_cloud.is_authenticated = True
  1202. mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
  1203. mocker.patch(
  1204. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1205. )
  1206. mocker.patch(
  1207. "custom_components.tuya_local.config_flow.scan_for_device",
  1208. side_effect=OSError("network unreachable"),
  1209. )
  1210. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1211. await hass.config_entries.flow.async_configure(
  1212. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1213. )
  1214. await hass.config_entries.flow.async_configure(
  1215. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1216. )
  1217. result = await hass.config_entries.flow.async_configure(
  1218. flow["flow_id"], user_input={}
  1219. )
  1220. assert result["type"] == "form"
  1221. assert result["step_id"] == "local"
  1222. # ---------------------------------------------------------------------------
  1223. # async_test_connection with fixed protocol
  1224. # ---------------------------------------------------------------------------
  1225. @pytest.mark.asyncio
  1226. async def test_async_test_connection_fixed_protocol_success(hass, mocker):
  1227. """Test connection with a fixed protocol version (not auto)."""
  1228. mock_device = mocker.patch(
  1229. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  1230. )
  1231. mock_instance = mocker.AsyncMock()
  1232. mock_instance.has_returned_state = True
  1233. mock_device.return_value = mock_instance
  1234. device = await config_flow.async_test_connection(
  1235. {
  1236. CONF_DEVICE_ID: "deviceid",
  1237. CONF_LOCAL_KEY: TESTKEY,
  1238. CONF_HOST: "hostname",
  1239. CONF_PROTOCOL_VERSION: 3.3,
  1240. },
  1241. hass,
  1242. )
  1243. assert device == mock_instance
  1244. @pytest.mark.asyncio
  1245. async def test_async_test_connection_fixed_protocol_no_state(hass, mocker):
  1246. """Test fixed protocol returns None when device has no state."""
  1247. mock_device = mocker.patch(
  1248. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  1249. )
  1250. mock_instance = mocker.AsyncMock()
  1251. mock_instance.has_returned_state = False
  1252. mock_device.return_value = mock_instance
  1253. device = await config_flow.async_test_connection(
  1254. {
  1255. CONF_DEVICE_ID: "deviceid",
  1256. CONF_LOCAL_KEY: TESTKEY,
  1257. CONF_HOST: "hostname",
  1258. CONF_PROTOCOL_VERSION: 3.3,
  1259. },
  1260. hass,
  1261. )
  1262. assert device is None
  1263. @pytest.mark.asyncio
  1264. async def test_async_test_connection_fixed_protocol_exception(hass, mocker):
  1265. """Test fixed protocol returns None on exception."""
  1266. mock_device = mocker.patch(
  1267. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  1268. )
  1269. mock_instance = mocker.AsyncMock()
  1270. mock_instance.async_refresh = AsyncMock(side_effect=Exception("timeout"))
  1271. mock_device.return_value = mock_instance
  1272. device = await config_flow.async_test_connection(
  1273. {
  1274. CONF_DEVICE_ID: "deviceid",
  1275. CONF_LOCAL_KEY: TESTKEY,
  1276. CONF_HOST: "hostname",
  1277. CONF_PROTOCOL_VERSION: 3.3,
  1278. },
  1279. hass,
  1280. )
  1281. assert device is None
  1282. @pytest.mark.asyncio
  1283. async def test_async_test_connection_auto_all_protocols_fail(hass, mocker):
  1284. """Test auto mode returns None when all protocols fail."""
  1285. mock_device = mocker.patch(
  1286. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  1287. )
  1288. mock_instance = mocker.AsyncMock()
  1289. mock_instance.has_returned_state = False
  1290. mock_instance._api = mocker.MagicMock()
  1291. mock_instance._api.parent = None
  1292. mock_device.return_value = mock_instance
  1293. device = await config_flow.async_test_connection(
  1294. {
  1295. CONF_DEVICE_ID: "deviceid",
  1296. CONF_LOCAL_KEY: TESTKEY,
  1297. CONF_HOST: "hostname",
  1298. CONF_PROTOCOL_VERSION: "auto",
  1299. },
  1300. hass,
  1301. )
  1302. assert device is None
  1303. # ---------------------------------------------------------------------------
  1304. # _device_name_placeholder
  1305. # ---------------------------------------------------------------------------
  1306. @pytest.mark.asyncio
  1307. async def test_device_name_placeholder_with_cloud_device(hass, mocker):
  1308. """Test _device_name_placeholder returns formatted name when cloud device set."""
  1309. mock_cloud = mocker.MagicMock()
  1310. mock_cloud.is_authenticated = True
  1311. mock_cloud.async_get_devices = AsyncMock(
  1312. return_value={
  1313. "dev1": {
  1314. "name": "My Light",
  1315. "product_name": "Smart Bulb",
  1316. "local_key": "key",
  1317. "online": True,
  1318. "is_hub": False,
  1319. "exists": False,
  1320. "ip": "192.168.1.5",
  1321. }
  1322. }
  1323. )
  1324. mocker.patch(
  1325. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1326. )
  1327. mocker.patch(
  1328. "custom_components.tuya_local.config_flow.scan_for_device",
  1329. return_value={"ip": None},
  1330. )
  1331. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1332. await hass.config_entries.flow.async_configure(
  1333. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1334. )
  1335. await hass.config_entries.flow.async_configure(
  1336. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1337. )
  1338. result = await hass.config_entries.flow.async_configure(
  1339. flow["flow_id"], user_input={}
  1340. )
  1341. # The local step description_placeholders should contain the device name
  1342. assert result["step_id"] == "local"
  1343. placeholder = result["description_placeholders"]["device_name"]
  1344. assert "My Light" in placeholder
  1345. assert "Smart Bulb" in placeholder
  1346. @pytest.mark.asyncio
  1347. async def test_device_name_placeholder_without_cloud_device(hass, mocker):
  1348. """Test _device_name_placeholder returns empty string when no cloud device."""
  1349. result = await hass.config_entries.flow.async_init(
  1350. DOMAIN, context={"source": "local"}
  1351. )
  1352. assert result["step_id"] == "local"
  1353. assert result["description_placeholders"]["device_name"] == ""
  1354. # ---------------------------------------------------------------------------
  1355. # async_step_select_type with auto-detected protocol
  1356. # ---------------------------------------------------------------------------
  1357. @pytest.mark.asyncio
  1358. async def test_flow_select_type_shows_auto_detected_form(hass, mocker):
  1359. """Test select_type shows the auto_detected variant when protocol was detected."""
  1360. mock_device = mocker.patch.object(config_flow.ConfigFlowHandler, "device")
  1361. mock_type = mocker.MagicMock()
  1362. mock_type.config_type = "smartplugv1"
  1363. mock_type.name = "Smart Plug"
  1364. mock_type.match_quality.return_value = 85
  1365. mock_type.product_display_entries.return_value = [(None, None)]
  1366. mock_device.async_possible_types = mocker.AsyncMock(return_value=[mock_type])
  1367. mock_device._get_cached_state.return_value = {"1": True}
  1368. mock_device._product_ids = []
  1369. mocker.patch.object(
  1370. config_flow.ConfigFlowHandler,
  1371. "_auto_detected_protocol",
  1372. new_callable=lambda: property(lambda self: 3.3),
  1373. create=True,
  1374. )
  1375. result = await hass.config_entries.flow.async_init(
  1376. DOMAIN, context={"source": "select_type"}
  1377. )
  1378. # Either select_type or select_type_auto_detected depending on attribute
  1379. assert result["step_id"] in ("select_type", "select_type_auto_detected")
  1380. # ---------------------------------------------------------------------------
  1381. # choose_entities with cloud device name as default
  1382. # ---------------------------------------------------------------------------
  1383. @pytest.mark.asyncio
  1384. async def test_flow_choose_entities_uses_cloud_name_as_default(
  1385. hass, bypass_setup, mocker
  1386. ):
  1387. """Test choose_entities uses cloud device name as the default entity name."""
  1388. mocker.patch.dict(config_flow.ConfigFlowHandler.data, {CONF_TYPE: "smartplugv1"})
  1389. # Patch __cloud_device on the handler class
  1390. mocker.patch.object(
  1391. config_flow.ConfigFlowHandler,
  1392. "_ConfigFlowHandler__cloud_device",
  1393. new={"name": "My Cloud Device", "product_name": "Plug"},
  1394. create=True,
  1395. )
  1396. result = await hass.config_entries.flow.async_init(
  1397. DOMAIN, context={"source": "choose_entities"}
  1398. )
  1399. assert result["type"] == "form"
  1400. assert result["step_id"] == "choose_entities"
  1401. # The schema default should be the cloud device name
  1402. schema = result["data_schema"]
  1403. # Validate it accepts the cloud device name
  1404. validated = schema({CONF_NAME: "My Cloud Device"})
  1405. assert validated[CONF_NAME] == "My Cloud Device"