test_config_flow.py 54 KB

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