test_config_flow.py 56 KB

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