test_config_flow.py 53 KB

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