test_config_flow.py 56 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715
  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 entry.minor_version == 22
  328. assert registry.async_get(entity.entity_id).unique_id == "gatewayid/001-climate"
  329. @pytest.mark.asyncio
  330. async def test_flow_user_init(hass, mocker):
  331. """Test the initialisation of the form in the first page of the manual config flow path."""
  332. result = await hass.config_entries.flow.async_init(
  333. DOMAIN, context={"source": "local"}
  334. )
  335. expected = {
  336. "data_schema": mocker.ANY,
  337. "description_placeholders": mocker.ANY,
  338. "errors": {},
  339. "flow_id": mocker.ANY,
  340. "handler": DOMAIN,
  341. "step_id": "local",
  342. "type": "form",
  343. "last_step": mocker.ANY,
  344. "preview": mocker.ANY,
  345. }
  346. assert expected == result
  347. # Check the schema. Simple comparison does not work since they are not
  348. # the same object
  349. try:
  350. result["data_schema"](
  351. {CONF_DEVICE_ID: "test", CONF_LOCAL_KEY: TESTKEY, CONF_HOST: "test"}
  352. )
  353. except vol.MultipleInvalid:
  354. assert False
  355. try:
  356. result["data_schema"]({CONF_DEVICE_ID: "missing_some"})
  357. assert False
  358. except vol.MultipleInvalid:
  359. pass
  360. @pytest.mark.asyncio
  361. async def test_flow_user_init_protocol_options_are_strings(hass, mocker):
  362. """Test that protocol version dropdown uses strings, not floats."""
  363. result = await hass.config_entries.flow.async_init(
  364. DOMAIN, context={"source": "local"}
  365. )
  366. schema = result["data_schema"]
  367. # Validate that string protocol versions are accepted
  368. schema(
  369. {
  370. CONF_DEVICE_ID: "test",
  371. CONF_LOCAL_KEY: TESTKEY,
  372. CONF_HOST: "test",
  373. CONF_PROTOCOL_VERSION: "3.3",
  374. CONF_POLL_ONLY: False,
  375. }
  376. )
  377. # Validate that float protocol versions are rejected
  378. with pytest.raises(vol.MultipleInvalid):
  379. schema(
  380. {
  381. CONF_DEVICE_ID: "test",
  382. CONF_LOCAL_KEY: TESTKEY,
  383. CONF_HOST: "test",
  384. CONF_PROTOCOL_VERSION: 3.3,
  385. CONF_POLL_ONLY: False,
  386. }
  387. )
  388. @pytest.mark.asyncio
  389. async def test_async_test_connection_valid(hass, mocker):
  390. """Test that device is returned when connection is valid."""
  391. mock_device = mocker.patch(
  392. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  393. )
  394. mock_instance = mocker.AsyncMock()
  395. mock_instance.has_returned_state = True
  396. mock_instance.pause = mocker.MagicMock()
  397. mock_instance.resume = mocker.MagicMock()
  398. mock_device.return_value = mock_instance
  399. hass.data[DOMAIN] = {"deviceid": {"device": mock_instance}}
  400. device = await config_flow.async_test_connection(
  401. {
  402. CONF_DEVICE_ID: "deviceid",
  403. CONF_LOCAL_KEY: TESTKEY,
  404. CONF_HOST: "hostname",
  405. CONF_PROTOCOL_VERSION: "auto",
  406. },
  407. hass,
  408. )
  409. assert device == mock_instance
  410. mock_instance.pause.assert_called_once()
  411. mock_instance.resume.assert_called_once()
  412. @pytest.mark.asyncio
  413. async def test_async_test_connection_for_subdevice_valid(hass, mocker):
  414. """Test that subdevice is returned when connection is valid."""
  415. mock_device = mocker.patch(
  416. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  417. )
  418. mock_instance = mocker.AsyncMock()
  419. mock_instance.has_returned_state = True
  420. mock_instance.pause = mocker.MagicMock()
  421. mock_instance.resume = mocker.MagicMock()
  422. mock_device.return_value = mock_instance
  423. hass.data[DOMAIN] = {"deviceid/subdeviceid": {"device": mock_instance}}
  424. device = await config_flow.async_test_connection(
  425. {
  426. CONF_DEVICE_ID: "deviceid",
  427. CONF_LOCAL_KEY: TESTKEY,
  428. CONF_HOST: "hostname",
  429. CONF_PROTOCOL_VERSION: "auto",
  430. CONF_DEVICE_CID: "subdeviceid",
  431. },
  432. hass,
  433. )
  434. assert device == mock_instance
  435. mock_instance.pause.assert_called_once()
  436. mock_instance.resume.assert_called_once()
  437. @pytest.mark.asyncio
  438. async def test_async_test_connection_invalid(hass, mocker):
  439. """Test that None is returned when connection is invalid."""
  440. mock_device = mocker.patch(
  441. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  442. )
  443. mock_instance = mocker.AsyncMock()
  444. mock_instance.has_returned_state = False
  445. mock_instance._api = mocker.MagicMock()
  446. mock_device.return_value = mock_instance
  447. device = await config_flow.async_test_connection(
  448. {
  449. CONF_DEVICE_ID: "deviceid",
  450. CONF_LOCAL_KEY: TESTKEY,
  451. CONF_HOST: "hostname",
  452. CONF_PROTOCOL_VERSION: "auto",
  453. },
  454. hass,
  455. )
  456. assert device is None
  457. @pytest.mark.asyncio
  458. async def test_flow_user_init_invalid_config(hass, mocker):
  459. """Test errors populated when config is invalid."""
  460. mocker.patch(
  461. "custom_components.tuya_local.config_flow.async_test_connection",
  462. return_value=None,
  463. )
  464. flow = await hass.config_entries.flow.async_init(
  465. DOMAIN, context={"source": "local"}
  466. )
  467. result = await hass.config_entries.flow.async_configure(
  468. flow["flow_id"],
  469. user_input={
  470. CONF_DEVICE_ID: "deviceid",
  471. CONF_HOST: "hostname",
  472. CONF_LOCAL_KEY: "badkey",
  473. CONF_PROTOCOL_VERSION: "auto",
  474. CONF_POLL_ONLY: False,
  475. },
  476. )
  477. assert {"base": "connection"} == result["errors"]
  478. def setup_device_mock(mock, mocker, failure=False, devtype="test"):
  479. mock_type = mocker.MagicMock()
  480. mock_type.legacy_type = devtype
  481. mock_type.config_type = devtype
  482. mock_type.match_quality.return_value = 100
  483. mock_type.product_display_entries.return_value = [(None, None)]
  484. mock.async_possible_types = mocker.AsyncMock(
  485. return_value=[mock_type] if not failure else []
  486. )
  487. @pytest.mark.asyncio
  488. async def test_flow_user_init_data_valid(hass, mocker):
  489. """Test we advance to the next step when connection config is valid."""
  490. mock_device = mocker.MagicMock()
  491. mock_device._protocol_configured = "auto"
  492. setup_device_mock(mock_device, mocker)
  493. mocker.patch(
  494. "custom_components.tuya_local.config_flow.async_test_connection",
  495. return_value=mock_device,
  496. )
  497. flow = await hass.config_entries.flow.async_init(
  498. DOMAIN, context={"source": "local"}
  499. )
  500. result = await hass.config_entries.flow.async_configure(
  501. flow["flow_id"],
  502. user_input={
  503. CONF_DEVICE_ID: "deviceid",
  504. CONF_HOST: "hostname",
  505. CONF_LOCAL_KEY: TESTKEY,
  506. },
  507. )
  508. assert "form" == result["type"]
  509. assert "select_type" == result["step_id"]
  510. @pytest.mark.asyncio
  511. async def test_flow_select_type_init(hass, mocker):
  512. """Test the initialisation of the form in the 2nd step of the config flow."""
  513. mock_device = mocker.patch.object(config_flow.ConfigFlowHandler, "device")
  514. setup_device_mock(mock_device, mocker)
  515. result = await hass.config_entries.flow.async_init(
  516. DOMAIN, context={"source": "select_type"}
  517. )
  518. expected = {
  519. "data_schema": mocker.ANY,
  520. "description_placeholders": {"device_name": ""},
  521. "errors": None,
  522. "flow_id": mocker.ANY,
  523. "handler": DOMAIN,
  524. "step_id": "select_type",
  525. "type": "form",
  526. "last_step": mocker.ANY,
  527. "preview": mocker.ANY,
  528. }
  529. assert expected == result
  530. # Check the schema. Simple comparison does not work since they are not
  531. # the same object
  532. try:
  533. result["data_schema"]({CONF_TYPE: "test||||"})
  534. except vol.MultipleInvalid:
  535. assert False
  536. try:
  537. result["data_schema"]({CONF_TYPE: "not_test||||"})
  538. assert False
  539. except vol.MultipleInvalid:
  540. pass
  541. @pytest.mark.asyncio
  542. async def test_flow_select_type_aborts_when_no_match(hass, mocker):
  543. """Test the flow aborts when an unsupported device is used."""
  544. mock_device = mocker.patch.object(config_flow.ConfigFlowHandler, "device")
  545. setup_device_mock(mock_device, mocker, failure=True)
  546. result = await hass.config_entries.flow.async_init(
  547. DOMAIN, context={"source": "select_type"}
  548. )
  549. assert result["type"] == "abort"
  550. assert result["reason"] == "not_supported"
  551. @pytest.mark.asyncio
  552. async def test_flow_select_type_data_valid(hass, mocker):
  553. """Test the flow continues when valid data is supplied."""
  554. mock_device = mocker.patch.object(config_flow.ConfigFlowHandler, "device")
  555. setup_device_mock(mock_device, mocker, devtype="smartplugv1")
  556. flow = await hass.config_entries.flow.async_init(
  557. DOMAIN, context={"source": "select_type"}
  558. )
  559. result = await hass.config_entries.flow.async_configure(
  560. flow["flow_id"],
  561. user_input={CONF_TYPE: "smartplugv1||||"},
  562. )
  563. assert "form" == result["type"]
  564. assert "choose_entities" == result["step_id"]
  565. @pytest.mark.asyncio
  566. async def test_flow_choose_entities_init(hass, mocker):
  567. """Test the initialisation of the form in the 3rd step of the config flow."""
  568. mocker.patch.dict(config_flow.ConfigFlowHandler.data, {CONF_TYPE: "smartplugv1"})
  569. result = await hass.config_entries.flow.async_init(
  570. DOMAIN, context={"source": "choose_entities"}
  571. )
  572. expected = {
  573. "data_schema": mocker.ANY,
  574. "description_placeholders": {"device_name": ""},
  575. "errors": None,
  576. "flow_id": mocker.ANY,
  577. "handler": DOMAIN,
  578. "step_id": "choose_entities",
  579. "type": "form",
  580. "last_step": mocker.ANY,
  581. "preview": mocker.ANY,
  582. }
  583. assert expected == result
  584. # Check the schema. Simple comparison does not work since they are not
  585. # the same object
  586. try:
  587. result["data_schema"]({CONF_NAME: "test"})
  588. except vol.MultipleInvalid:
  589. assert False
  590. try:
  591. result["data_schema"]({"climate": True})
  592. assert False
  593. except vol.MultipleInvalid:
  594. pass
  595. @pytest.mark.asyncio
  596. async def test_flow_choose_entities_creates_config_entry(hass, bypass_setup, mocker):
  597. """Test the flow ends when data is valid."""
  598. mocker.patch.dict(
  599. config_flow.ConfigFlowHandler.data,
  600. {
  601. CONF_DEVICE_ID: "deviceid",
  602. CONF_LOCAL_KEY: TESTKEY,
  603. CONF_HOST: "hostname",
  604. CONF_POLL_ONLY: False,
  605. CONF_PROTOCOL_VERSION: "auto",
  606. CONF_TYPE: "kogan_kahtp_heater",
  607. CONF_DEVICE_CID: None,
  608. },
  609. )
  610. flow = await hass.config_entries.flow.async_init(
  611. DOMAIN, context={"source": "choose_entities"}
  612. )
  613. result = await hass.config_entries.flow.async_configure(
  614. flow["flow_id"],
  615. user_input={
  616. CONF_NAME: "test",
  617. },
  618. )
  619. expected = {
  620. "version": 13,
  621. "minor_version": mocker.ANY,
  622. "context": {"source": "choose_entities"},
  623. "type": FlowResultType.CREATE_ENTRY,
  624. "flow_id": mocker.ANY,
  625. "handler": DOMAIN,
  626. "title": "test",
  627. "description": None,
  628. "description_placeholders": None,
  629. "result": mocker.ANY,
  630. "subentries": (),
  631. "options": {},
  632. "data": {
  633. CONF_DEVICE_ID: "deviceid",
  634. CONF_HOST: "hostname",
  635. CONF_LOCAL_KEY: TESTKEY,
  636. CONF_POLL_ONLY: False,
  637. CONF_PROTOCOL_VERSION: "auto",
  638. CONF_TYPE: "kogan_kahtp_heater",
  639. CONF_DEVICE_CID: None,
  640. },
  641. }
  642. assert expected == result
  643. @pytest.mark.asyncio
  644. async def test_options_flow_init(hass, bypass_data_fetch):
  645. """Test config flow options."""
  646. config_entry = MockConfigEntry(
  647. domain=DOMAIN,
  648. version=13,
  649. unique_id="uniqueid",
  650. data={
  651. CONF_DEVICE_ID: "deviceid",
  652. CONF_HOST: "hostname",
  653. CONF_LOCAL_KEY: TESTKEY,
  654. CONF_NAME: "test",
  655. CONF_POLL_ONLY: False,
  656. CONF_PROTOCOL_VERSION: "auto",
  657. CONF_TYPE: "smartplugv1",
  658. CONF_DEVICE_CID: "",
  659. },
  660. )
  661. config_entry.add_to_hass(hass)
  662. assert await hass.config_entries.async_setup(config_entry.entry_id)
  663. await hass.async_block_till_done()
  664. # show initial form
  665. result = await hass.config_entries.options.async_init(config_entry.entry_id)
  666. assert "form" == result["type"]
  667. assert "user" == result["step_id"]
  668. assert {} == result["errors"]
  669. assert result["data_schema"](
  670. {
  671. CONF_HOST: "hostname",
  672. CONF_LOCAL_KEY: TESTKEY,
  673. }
  674. )
  675. @pytest.mark.asyncio
  676. async def test_options_flow_modifies_config(hass, bypass_setup, mocker):
  677. mock_device = mocker.MagicMock()
  678. mocker.patch(
  679. "custom_components.tuya_local.config_flow.async_test_connection",
  680. return_value=mock_device,
  681. )
  682. config_entry = MockConfigEntry(
  683. domain=DOMAIN,
  684. version=13,
  685. unique_id="uniqueid",
  686. data={
  687. CONF_DEVICE_ID: "deviceid",
  688. CONF_HOST: "hostname",
  689. CONF_LOCAL_KEY: TESTKEY,
  690. CONF_NAME: "test",
  691. CONF_POLL_ONLY: False,
  692. CONF_PROTOCOL_VERSION: "auto",
  693. CONF_TYPE: "ble_pt216_temp_humidity",
  694. CONF_DEVICE_CID: "subdeviceid",
  695. },
  696. )
  697. config_entry.add_to_hass(hass)
  698. assert await hass.config_entries.async_setup(config_entry.entry_id)
  699. await hass.async_block_till_done()
  700. # show initial form
  701. form = await hass.config_entries.options.async_init(config_entry.entry_id)
  702. # submit updated config
  703. result = await hass.config_entries.options.async_configure(
  704. form["flow_id"],
  705. user_input={
  706. CONF_HOST: "new_hostname",
  707. CONF_LOCAL_KEY: "new_key",
  708. CONF_POLL_ONLY: False,
  709. CONF_PROTOCOL_VERSION: "3.3",
  710. },
  711. )
  712. expected = {
  713. CONF_HOST: "new_hostname",
  714. CONF_LOCAL_KEY: "new_key",
  715. CONF_POLL_ONLY: False,
  716. CONF_PROTOCOL_VERSION: 3.3,
  717. }
  718. assert "create_entry" == result["type"]
  719. assert "" == result["title"]
  720. assert expected == result["data"]
  721. @pytest.mark.asyncio
  722. async def test_options_flow_fails_when_connection_fails(
  723. hass, bypass_data_fetch, mocker
  724. ):
  725. mocker.patch(
  726. "custom_components.tuya_local.config_flow.async_test_connection",
  727. return_value=None,
  728. )
  729. config_entry = MockConfigEntry(
  730. domain=DOMAIN,
  731. version=13,
  732. unique_id="uniqueid",
  733. data={
  734. CONF_DEVICE_ID: "deviceid",
  735. CONF_HOST: "hostname",
  736. CONF_LOCAL_KEY: TESTKEY,
  737. CONF_NAME: "test",
  738. CONF_POLL_ONLY: False,
  739. CONF_PROTOCOL_VERSION: "auto",
  740. CONF_TYPE: "smartplugv1",
  741. CONF_DEVICE_CID: "",
  742. },
  743. )
  744. config_entry.add_to_hass(hass)
  745. assert await hass.config_entries.async_setup(config_entry.entry_id)
  746. await hass.async_block_till_done()
  747. # show initial form
  748. form = await hass.config_entries.options.async_init(config_entry.entry_id)
  749. # submit updated config
  750. result = await hass.config_entries.options.async_configure(
  751. form["flow_id"],
  752. user_input={
  753. CONF_HOST: "new_hostname",
  754. CONF_LOCAL_KEY: "new_key",
  755. },
  756. )
  757. assert "form" == result["type"]
  758. assert "user" == result["step_id"]
  759. assert {"base": "connection"} == result["errors"]
  760. @pytest.mark.asyncio
  761. async def test_options_flow_fails_when_config_is_missing(hass, mocker):
  762. mock_device = mocker.MagicMock()
  763. mocker.patch(
  764. "custom_components.tuya_local.config_flow.async_test_connection",
  765. return_value=mock_device,
  766. )
  767. config_entry = MockConfigEntry(
  768. domain=DOMAIN,
  769. version=13,
  770. unique_id="uniqueid",
  771. data={
  772. CONF_DEVICE_ID: "deviceid",
  773. CONF_HOST: "hostname",
  774. CONF_LOCAL_KEY: TESTKEY,
  775. CONF_NAME: "test",
  776. CONF_POLL_ONLY: False,
  777. CONF_PROTOCOL_VERSION: "auto",
  778. CONF_TYPE: "non_existing",
  779. },
  780. )
  781. config_entry.add_to_hass(hass)
  782. await hass.config_entries.async_setup(config_entry.entry_id)
  783. await hass.async_block_till_done()
  784. # show initial form
  785. result = await hass.config_entries.options.async_init(config_entry.entry_id)
  786. assert result["type"] == "abort"
  787. assert result["reason"] == "not_supported"
  788. def test_migration_gets_correct_device_id():
  789. """Test that migration gets the correct device id."""
  790. # Normal device
  791. entry = MockConfigEntry(
  792. domain=DOMAIN,
  793. version=1,
  794. title="test",
  795. data={
  796. CONF_DEVICE_ID: "deviceid",
  797. CONF_HOST: "hostname",
  798. CONF_LOCAL_KEY: TESTKEY,
  799. CONF_TYPE: "auto",
  800. },
  801. )
  802. assert get_device_unique_id(entry) == "deviceid"
  803. # ---------------------------------------------------------------------------
  804. # async_step_user
  805. # ---------------------------------------------------------------------------
  806. @pytest.mark.asyncio
  807. async def test_flow_user_shows_form(hass):
  808. """Test the user step shows the setup mode form when no input."""
  809. result = await hass.config_entries.flow.async_init(
  810. DOMAIN, context={"source": "user"}
  811. )
  812. assert result["type"] == "form"
  813. assert result["step_id"] == "user"
  814. @pytest.mark.asyncio
  815. async def test_flow_user_manual_goes_to_local(hass):
  816. """Test that choosing 'manual' advances to the local step."""
  817. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  818. result = await hass.config_entries.flow.async_configure(
  819. flow["flow_id"], user_input={"setup_mode": "manual"}
  820. )
  821. assert result["type"] == "form"
  822. assert result["step_id"] == "local"
  823. @pytest.mark.asyncio
  824. async def test_flow_user_cloud_authenticated_goes_to_choose_device(hass, mocker):
  825. """Test cloud mode when already authenticated goes to choose_device."""
  826. mock_cloud = mocker.MagicMock()
  827. mock_cloud.is_authenticated = True
  828. mock_cloud.async_get_devices = AsyncMock(
  829. return_value={
  830. "dev1": {
  831. "name": "Light",
  832. "product_name": "Smart Light",
  833. "local_key": "key1",
  834. "online": True,
  835. "is_hub": False,
  836. "exists": False,
  837. }
  838. }
  839. )
  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"}
  846. )
  847. assert result["type"] == "form"
  848. assert result["step_id"] == "choose_device"
  849. @pytest.mark.asyncio
  850. async def test_flow_user_cloud_not_authenticated_goes_to_cloud_step(hass, mocker):
  851. """Test cloud mode when not authenticated goes to the cloud (QR) step."""
  852. mock_cloud = mocker.MagicMock()
  853. mock_cloud.is_authenticated = False
  854. mocker.patch(
  855. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  856. )
  857. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  858. result = await hass.config_entries.flow.async_configure(
  859. flow["flow_id"], user_input={"setup_mode": "cloud"}
  860. )
  861. assert result["type"] == "form"
  862. assert result["step_id"] == "cloud"
  863. @pytest.mark.asyncio
  864. async def test_flow_user_cloud_fresh_login_logs_out_and_goes_to_cloud(hass, mocker):
  865. """Test cloud_fresh_login forces logout then goes to cloud step."""
  866. mock_cloud = mocker.MagicMock()
  867. mock_cloud.is_authenticated = False
  868. mock_cloud.logout = mocker.MagicMock()
  869. mocker.patch(
  870. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  871. )
  872. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  873. result = await hass.config_entries.flow.async_configure(
  874. flow["flow_id"], user_input={"setup_mode": "cloud_fresh_login"}
  875. )
  876. mock_cloud.logout.assert_called_once()
  877. assert result["type"] == "form"
  878. assert result["step_id"] == "cloud"
  879. @pytest.mark.asyncio
  880. async def test_flow_user_cloud_exception_goes_to_cloud_step(hass, mocker):
  881. """Test that cloud exceptions cause re-auth (go to cloud step)."""
  882. mock_cloud = mocker.MagicMock()
  883. mock_cloud.is_authenticated = True
  884. mock_cloud.async_get_devices = AsyncMock(side_effect=Exception("network error"))
  885. mocker.patch(
  886. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  887. )
  888. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  889. result = await hass.config_entries.flow.async_configure(
  890. flow["flow_id"], user_input={"setup_mode": "cloud"}
  891. )
  892. assert result["type"] == "form"
  893. assert result["step_id"] == "cloud"
  894. # ---------------------------------------------------------------------------
  895. # async_step_cloud
  896. # ---------------------------------------------------------------------------
  897. @pytest.mark.asyncio
  898. async def test_flow_cloud_shows_form(hass, mocker):
  899. """Test cloud step shows the user_code form."""
  900. mocker.patch("custom_components.tuya_local.config_flow.Cloud")
  901. result = await hass.config_entries.flow.async_init(
  902. DOMAIN, context={"source": "cloud"}
  903. )
  904. assert result["type"] == "form"
  905. assert result["step_id"] == "cloud"
  906. @pytest.mark.asyncio
  907. async def test_flow_cloud_success_goes_to_scan(hass, mocker):
  908. """Test entering a user code that succeeds goes to QR scan step."""
  909. mock_cloud = mocker.MagicMock()
  910. mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN_123")
  911. mocker.patch(
  912. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  913. )
  914. flow = await hass.config_entries.flow.async_init(
  915. DOMAIN, context={"source": "cloud"}
  916. )
  917. result = await hass.config_entries.flow.async_configure(
  918. flow["flow_id"], user_input={"user_code": "MY_CODE"}
  919. )
  920. assert result["type"] == "form"
  921. assert result["step_id"] == "scan"
  922. @pytest.mark.asyncio
  923. async def test_flow_cloud_failure_shows_error(hass, mocker):
  924. """Test entering a bad user code stays on cloud step with error."""
  925. mock_cloud = mocker.MagicMock()
  926. mock_cloud.async_get_qr_code = AsyncMock(return_value=False)
  927. mock_cloud.last_error = {"msg": "Invalid code", "code": 1001}
  928. mocker.patch(
  929. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  930. )
  931. flow = await hass.config_entries.flow.async_init(
  932. DOMAIN, context={"source": "cloud"}
  933. )
  934. result = await hass.config_entries.flow.async_configure(
  935. flow["flow_id"], user_input={"user_code": "BAD_CODE"}
  936. )
  937. assert result["type"] == "form"
  938. assert result["step_id"] == "cloud"
  939. assert result["errors"] == {"base": "login_error"}
  940. # ---------------------------------------------------------------------------
  941. # async_step_scan
  942. # ---------------------------------------------------------------------------
  943. @pytest.mark.asyncio
  944. async def test_flow_scan_shows_qr_form(hass, mocker):
  945. """Test the scan step shows the QR code form."""
  946. mock_cloud = mocker.MagicMock()
  947. mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN")
  948. mocker.patch(
  949. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  950. )
  951. # Get to scan via cloud step
  952. flow = await hass.config_entries.flow.async_init(
  953. DOMAIN, context={"source": "cloud"}
  954. )
  955. result = await hass.config_entries.flow.async_configure(
  956. flow["flow_id"], user_input={"user_code": "CODE"}
  957. )
  958. assert result["step_id"] == "scan"
  959. @pytest.mark.asyncio
  960. async def test_flow_scan_login_success_goes_to_choose_device(hass, mocker):
  961. """Test scanning QR and successful login goes to choose_device."""
  962. mock_cloud = mocker.MagicMock()
  963. mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN")
  964. mock_cloud.async_login = AsyncMock(return_value=True)
  965. mock_cloud.async_get_devices = AsyncMock(
  966. return_value={
  967. "dev1": {
  968. "name": "Plug",
  969. "product_name": "Smart Plug",
  970. "local_key": "key",
  971. "online": True,
  972. "is_hub": False,
  973. "exists": False,
  974. }
  975. }
  976. )
  977. mocker.patch(
  978. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  979. )
  980. flow = await hass.config_entries.flow.async_init(
  981. DOMAIN, context={"source": "cloud"}
  982. )
  983. await hass.config_entries.flow.async_configure(
  984. flow["flow_id"], user_input={"user_code": "CODE"}
  985. )
  986. result = await hass.config_entries.flow.async_configure(
  987. flow["flow_id"], user_input={}
  988. )
  989. assert result["type"] == "form"
  990. assert result["step_id"] == "choose_device"
  991. @pytest.mark.asyncio
  992. async def test_flow_scan_login_failure_stays_on_scan(hass, mocker):
  993. """Test failed login stays on scan step with error."""
  994. mock_cloud = mocker.MagicMock()
  995. mock_cloud.async_get_qr_code = AsyncMock(return_value="QR_TOKEN")
  996. mock_cloud.async_login = AsyncMock(return_value=False)
  997. mock_cloud.last_error = {"msg": "Auth failed", "code": 2000}
  998. mocker.patch(
  999. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1000. )
  1001. flow = await hass.config_entries.flow.async_init(
  1002. DOMAIN, context={"source": "cloud"}
  1003. )
  1004. await hass.config_entries.flow.async_configure(
  1005. flow["flow_id"], user_input={"user_code": "CODE"}
  1006. )
  1007. result = await hass.config_entries.flow.async_configure(
  1008. flow["flow_id"], user_input={}
  1009. )
  1010. assert result["type"] == "form"
  1011. assert result["step_id"] == "scan"
  1012. assert result["errors"] == {"base": "login_error"}
  1013. # ---------------------------------------------------------------------------
  1014. # async_step_choose_device
  1015. # ---------------------------------------------------------------------------
  1016. def _make_cloud_devices(include_hub=False, include_offline=False):
  1017. devices = {
  1018. "dev1": {
  1019. "name": "Smart Light",
  1020. "product_name": "Light",
  1021. "local_key": "key1",
  1022. "online": True,
  1023. "is_hub": False,
  1024. "exists": False,
  1025. "ip": "192.168.1.10",
  1026. }
  1027. }
  1028. if include_hub:
  1029. devices["hub1"] = {
  1030. "name": "Zigbee Hub",
  1031. "product_name": "Hub",
  1032. "local_key": "hubkey",
  1033. "online": True,
  1034. "is_hub": True,
  1035. "exists": False,
  1036. "ip": "192.168.1.1",
  1037. }
  1038. if include_offline:
  1039. devices["dev2"] = {
  1040. "name": "Offline Device",
  1041. "product_name": "Sensor",
  1042. "local_key": "key2",
  1043. "online": False,
  1044. "is_hub": False,
  1045. "exists": False,
  1046. "ip": "192.168.1.20",
  1047. }
  1048. return devices
  1049. @pytest.mark.asyncio
  1050. async def test_flow_choose_device_shows_form(hass, mocker):
  1051. """Test the choose_device step shows the device list form."""
  1052. mock_cloud = mocker.MagicMock()
  1053. mock_cloud.is_authenticated = True
  1054. mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
  1055. mocker.patch(
  1056. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1057. )
  1058. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1059. result = await hass.config_entries.flow.async_configure(
  1060. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1061. )
  1062. assert result["type"] == "form"
  1063. assert result["step_id"] == "choose_device"
  1064. @pytest.mark.asyncio
  1065. async def test_flow_choose_device_aborts_when_no_devices(hass, mocker):
  1066. """Test choose_device aborts when no new devices are available."""
  1067. mock_cloud = mocker.MagicMock()
  1068. mock_cloud.is_authenticated = True
  1069. mock_cloud.async_get_devices = AsyncMock(return_value={})
  1070. mocker.patch(
  1071. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1072. )
  1073. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1074. result = await hass.config_entries.flow.async_configure(
  1075. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1076. )
  1077. assert result["type"] == "abort"
  1078. assert result["reason"] == "no_devices"
  1079. @pytest.mark.asyncio
  1080. async def test_flow_choose_device_direct_device_no_hub_goes_to_search(hass, mocker):
  1081. """Test selecting a directly addressable device (no hub) goes to search."""
  1082. devices = _make_cloud_devices()
  1083. mock_cloud = mocker.MagicMock()
  1084. mock_cloud.is_authenticated = True
  1085. mock_cloud.async_get_devices = AsyncMock(return_value=devices)
  1086. mocker.patch(
  1087. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1088. )
  1089. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1090. await hass.config_entries.flow.async_configure(
  1091. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1092. )
  1093. result = await hass.config_entries.flow.async_configure(
  1094. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1095. )
  1096. assert result["type"] == "form"
  1097. assert result["step_id"] == "search"
  1098. @pytest.mark.asyncio
  1099. async def test_flow_choose_device_direct_device_with_hub_shows_error(hass, mocker):
  1100. """Test selecting a hub for a direct device shows an error."""
  1101. devices = _make_cloud_devices(include_hub=True)
  1102. mock_cloud = mocker.MagicMock()
  1103. mock_cloud.is_authenticated = True
  1104. mock_cloud.async_get_devices = AsyncMock(return_value=devices)
  1105. mocker.patch(
  1106. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1107. )
  1108. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1109. await hass.config_entries.flow.async_configure(
  1110. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1111. )
  1112. result = await hass.config_entries.flow.async_configure(
  1113. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "hub1"}
  1114. )
  1115. assert result["type"] == "form"
  1116. assert result["step_id"] == "choose_device"
  1117. assert result["errors"] == {"base": "does_not_need_hub"}
  1118. @pytest.mark.asyncio
  1119. async def test_flow_choose_device_indirect_device_with_hub_goes_to_search(hass, mocker):
  1120. """Test selecting an indirect device with a hub goes to search."""
  1121. devices = {
  1122. "subdev1": {
  1123. "name": "Sub Device",
  1124. "product_name": "Sensor",
  1125. "local_key": "subkey", # non-empty so it appears in list
  1126. "online": True,
  1127. "is_hub": False,
  1128. "exists": False,
  1129. "ip": "", # empty ip = indirect/sub-device
  1130. "node_id": "node123",
  1131. "uuid": "uuid123",
  1132. "product_id": "prod_sub",
  1133. },
  1134. "hub1": {
  1135. "name": "Hub",
  1136. "product_name": "Gateway",
  1137. "local_key": "hubkey",
  1138. "online": True,
  1139. "is_hub": True,
  1140. "exists": False,
  1141. "ip": "192.168.1.1",
  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": "hub1"}
  1156. )
  1157. assert result["type"] == "form"
  1158. assert result["step_id"] == "search"
  1159. @pytest.mark.asyncio
  1160. async def test_flow_choose_device_indirect_no_hub_shows_error(hass, mocker):
  1161. """Test selecting an indirect device without a hub shows an error."""
  1162. devices = {
  1163. "subdev1": {
  1164. "name": "Sub Device",
  1165. "product_name": "Sensor",
  1166. "local_key": "subkey", # non-empty so it appears in list
  1167. "online": True,
  1168. "is_hub": False,
  1169. "exists": False,
  1170. "ip": "", # empty ip = indirect/sub-device
  1171. }
  1172. }
  1173. mock_cloud = mocker.MagicMock()
  1174. mock_cloud.is_authenticated = True
  1175. mock_cloud.async_get_devices = AsyncMock(return_value=devices)
  1176. mocker.patch(
  1177. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1178. )
  1179. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1180. await hass.config_entries.flow.async_configure(
  1181. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1182. )
  1183. result = await hass.config_entries.flow.async_configure(
  1184. flow["flow_id"], user_input={"device_id": "subdev1", "hub_id": "None"}
  1185. )
  1186. assert result["type"] == "form"
  1187. assert result["step_id"] == "choose_device"
  1188. assert result["errors"] == {"base": "needs_hub"}
  1189. # ---------------------------------------------------------------------------
  1190. # async_step_search
  1191. # ---------------------------------------------------------------------------
  1192. @pytest.mark.asyncio
  1193. async def test_flow_search_shows_form(hass, mocker):
  1194. """Test the search step shows the scanning form."""
  1195. mock_cloud = mocker.MagicMock()
  1196. mock_cloud.is_authenticated = True
  1197. mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
  1198. mocker.patch(
  1199. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1200. )
  1201. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1202. await hass.config_entries.flow.async_configure(
  1203. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1204. )
  1205. result = await hass.config_entries.flow.async_configure(
  1206. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1207. )
  1208. assert result["type"] == "form"
  1209. assert result["step_id"] == "search"
  1210. @pytest.mark.asyncio
  1211. async def test_flow_search_found_device_goes_to_local(hass, mocker):
  1212. """Test that finding a device on the network advances to the local step."""
  1213. mock_cloud = mocker.MagicMock()
  1214. mock_cloud.is_authenticated = True
  1215. mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
  1216. mocker.patch(
  1217. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1218. )
  1219. mocker.patch(
  1220. "custom_components.tuya_local.config_flow.scan_for_device",
  1221. return_value={"ip": "192.168.1.50", "version": "3.3", "productKey": "pk123"},
  1222. )
  1223. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1224. await hass.config_entries.flow.async_configure(
  1225. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1226. )
  1227. await hass.config_entries.flow.async_configure(
  1228. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1229. )
  1230. result = await hass.config_entries.flow.async_configure(
  1231. flow["flow_id"], user_input={}
  1232. )
  1233. assert result["type"] == "form"
  1234. assert result["step_id"] == "local"
  1235. @pytest.mark.asyncio
  1236. async def test_flow_search_not_found_still_goes_to_local(hass, mocker):
  1237. """Test that not finding a device still advances to local step (blank IP)."""
  1238. mock_cloud = mocker.MagicMock()
  1239. mock_cloud.is_authenticated = True
  1240. mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
  1241. mocker.patch(
  1242. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1243. )
  1244. mocker.patch(
  1245. "custom_components.tuya_local.config_flow.scan_for_device",
  1246. return_value={"ip": None},
  1247. )
  1248. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1249. await hass.config_entries.flow.async_configure(
  1250. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1251. )
  1252. await hass.config_entries.flow.async_configure(
  1253. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1254. )
  1255. result = await hass.config_entries.flow.async_configure(
  1256. flow["flow_id"], user_input={}
  1257. )
  1258. assert result["type"] == "form"
  1259. assert result["step_id"] == "local"
  1260. @pytest.mark.asyncio
  1261. async def test_flow_search_oserror_still_goes_to_local(hass, mocker):
  1262. """Test that an OSError during scan still advances to local step."""
  1263. mock_cloud = mocker.MagicMock()
  1264. mock_cloud.is_authenticated = True
  1265. mock_cloud.async_get_devices = AsyncMock(return_value=_make_cloud_devices())
  1266. mocker.patch(
  1267. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1268. )
  1269. mocker.patch(
  1270. "custom_components.tuya_local.config_flow.scan_for_device",
  1271. side_effect=OSError("network unreachable"),
  1272. )
  1273. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1274. await hass.config_entries.flow.async_configure(
  1275. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1276. )
  1277. await hass.config_entries.flow.async_configure(
  1278. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1279. )
  1280. result = await hass.config_entries.flow.async_configure(
  1281. flow["flow_id"], user_input={}
  1282. )
  1283. assert result["type"] == "form"
  1284. assert result["step_id"] == "local"
  1285. # ---------------------------------------------------------------------------
  1286. # async_test_connection with fixed protocol
  1287. # ---------------------------------------------------------------------------
  1288. @pytest.mark.asyncio
  1289. async def test_async_test_connection_fixed_protocol_success(hass, mocker):
  1290. """Test connection with a fixed protocol version (not auto)."""
  1291. mock_device = mocker.patch(
  1292. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  1293. )
  1294. mock_instance = mocker.AsyncMock()
  1295. mock_instance.has_returned_state = True
  1296. mock_device.return_value = mock_instance
  1297. device = await config_flow.async_test_connection(
  1298. {
  1299. CONF_DEVICE_ID: "deviceid",
  1300. CONF_LOCAL_KEY: TESTKEY,
  1301. CONF_HOST: "hostname",
  1302. CONF_PROTOCOL_VERSION: 3.3,
  1303. },
  1304. hass,
  1305. )
  1306. assert device == mock_instance
  1307. @pytest.mark.asyncio
  1308. async def test_async_test_connection_fixed_protocol_no_state(hass, mocker):
  1309. """Test fixed protocol returns None when device has no state."""
  1310. mock_device = mocker.patch(
  1311. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  1312. )
  1313. mock_instance = mocker.AsyncMock()
  1314. mock_instance.has_returned_state = False
  1315. mock_device.return_value = mock_instance
  1316. device = await config_flow.async_test_connection(
  1317. {
  1318. CONF_DEVICE_ID: "deviceid",
  1319. CONF_LOCAL_KEY: TESTKEY,
  1320. CONF_HOST: "hostname",
  1321. CONF_PROTOCOL_VERSION: 3.3,
  1322. },
  1323. hass,
  1324. )
  1325. assert device is None
  1326. @pytest.mark.asyncio
  1327. async def test_async_test_connection_fixed_protocol_exception(hass, mocker):
  1328. """Test fixed protocol returns None on exception."""
  1329. mock_device = mocker.patch(
  1330. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  1331. )
  1332. mock_instance = mocker.AsyncMock()
  1333. mock_instance.async_refresh = AsyncMock(side_effect=Exception("timeout"))
  1334. mock_device.return_value = mock_instance
  1335. device = await config_flow.async_test_connection(
  1336. {
  1337. CONF_DEVICE_ID: "deviceid",
  1338. CONF_LOCAL_KEY: TESTKEY,
  1339. CONF_HOST: "hostname",
  1340. CONF_PROTOCOL_VERSION: 3.3,
  1341. },
  1342. hass,
  1343. )
  1344. assert device is None
  1345. @pytest.mark.asyncio
  1346. async def test_async_test_connection_auto_all_protocols_fail(hass, mocker):
  1347. """Test auto mode returns None when all protocols fail."""
  1348. mock_device = mocker.patch(
  1349. "custom_components.tuya_local.config_flow.TuyaLocalDevice"
  1350. )
  1351. mock_instance = mocker.AsyncMock()
  1352. mock_instance.has_returned_state = False
  1353. mock_instance._api = mocker.MagicMock()
  1354. mock_instance._api.parent = None
  1355. mock_device.return_value = mock_instance
  1356. device = await config_flow.async_test_connection(
  1357. {
  1358. CONF_DEVICE_ID: "deviceid",
  1359. CONF_LOCAL_KEY: TESTKEY,
  1360. CONF_HOST: "hostname",
  1361. CONF_PROTOCOL_VERSION: "auto",
  1362. },
  1363. hass,
  1364. )
  1365. assert device is None
  1366. # ---------------------------------------------------------------------------
  1367. # _device_name_placeholder
  1368. # ---------------------------------------------------------------------------
  1369. @pytest.mark.asyncio
  1370. async def test_device_name_placeholder_with_cloud_device(hass, mocker):
  1371. """Test _device_name_placeholder returns formatted name when cloud device set."""
  1372. mock_cloud = mocker.MagicMock()
  1373. mock_cloud.is_authenticated = True
  1374. mock_cloud.async_get_devices = AsyncMock(
  1375. return_value={
  1376. "dev1": {
  1377. "name": "My Light",
  1378. "product_name": "Smart Bulb",
  1379. "local_key": "key",
  1380. "online": True,
  1381. "is_hub": False,
  1382. "exists": False,
  1383. "ip": "192.168.1.5",
  1384. }
  1385. }
  1386. )
  1387. mocker.patch(
  1388. "custom_components.tuya_local.config_flow.Cloud", return_value=mock_cloud
  1389. )
  1390. mocker.patch(
  1391. "custom_components.tuya_local.config_flow.scan_for_device",
  1392. return_value={"ip": None},
  1393. )
  1394. flow = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
  1395. await hass.config_entries.flow.async_configure(
  1396. flow["flow_id"], user_input={"setup_mode": "cloud"}
  1397. )
  1398. await hass.config_entries.flow.async_configure(
  1399. flow["flow_id"], user_input={"device_id": "dev1", "hub_id": "None"}
  1400. )
  1401. result = await hass.config_entries.flow.async_configure(
  1402. flow["flow_id"], user_input={}
  1403. )
  1404. # The local step description_placeholders should contain the device name
  1405. assert result["step_id"] == "local"
  1406. placeholder = result["description_placeholders"]["device_name"]
  1407. assert "My Light" in placeholder
  1408. assert "Smart Bulb" in placeholder
  1409. @pytest.mark.asyncio
  1410. async def test_device_name_placeholder_without_cloud_device(hass, mocker):
  1411. """Test _device_name_placeholder returns empty string when no cloud device."""
  1412. result = await hass.config_entries.flow.async_init(
  1413. DOMAIN, context={"source": "local"}
  1414. )
  1415. assert result["step_id"] == "local"
  1416. assert result["description_placeholders"]["device_name"] == ""
  1417. # ---------------------------------------------------------------------------
  1418. # async_step_select_type with auto-detected protocol
  1419. # ---------------------------------------------------------------------------
  1420. @pytest.mark.asyncio
  1421. async def test_flow_select_type_shows_auto_detected_form(hass, mocker):
  1422. """Test select_type shows the auto_detected variant when protocol was detected."""
  1423. mock_device = mocker.patch.object(config_flow.ConfigFlowHandler, "device")
  1424. mock_type = mocker.MagicMock()
  1425. mock_type.config_type = "smartplugv1"
  1426. mock_type.name = "Smart Plug"
  1427. mock_type.match_quality.return_value = 85
  1428. mock_type.product_display_entries.return_value = [(None, None)]
  1429. mock_device.async_possible_types = mocker.AsyncMock(return_value=[mock_type])
  1430. mock_device._get_cached_state.return_value = {"1": True}
  1431. mock_device._product_ids = []
  1432. mocker.patch.object(
  1433. config_flow.ConfigFlowHandler,
  1434. "_auto_detected_protocol",
  1435. new_callable=lambda: property(lambda self: 3.3),
  1436. create=True,
  1437. )
  1438. result = await hass.config_entries.flow.async_init(
  1439. DOMAIN, context={"source": "select_type"}
  1440. )
  1441. # Either select_type or select_type_auto_detected depending on attribute
  1442. assert result["step_id"] in ("select_type", "select_type_auto_detected")
  1443. # ---------------------------------------------------------------------------
  1444. # choose_entities with cloud device name as default
  1445. # ---------------------------------------------------------------------------
  1446. @pytest.mark.asyncio
  1447. async def test_flow_choose_entities_uses_cloud_name_as_default(
  1448. hass, bypass_setup, mocker
  1449. ):
  1450. """Test choose_entities uses cloud device name as the default entity name."""
  1451. mocker.patch.dict(config_flow.ConfigFlowHandler.data, {CONF_TYPE: "smartplugv1"})
  1452. # Patch __cloud_device on the handler class
  1453. mocker.patch.object(
  1454. config_flow.ConfigFlowHandler,
  1455. "_ConfigFlowHandler__cloud_device",
  1456. new={"name": "My Cloud Device", "product_name": "Plug"},
  1457. create=True,
  1458. )
  1459. result = await hass.config_entries.flow.async_init(
  1460. DOMAIN, context={"source": "choose_entities"}
  1461. )
  1462. assert result["type"] == "form"
  1463. assert result["step_id"] == "choose_entities"
  1464. # The schema default should be the cloud device name
  1465. schema = result["data_schema"]
  1466. # Validate it accepts the cloud device name
  1467. validated = schema({CONF_NAME: "My Cloud Device"})
  1468. assert validated[CONF_NAME] == "My Cloud Device"
  1469. @pytest.mark.asyncio
  1470. async def test_flow_integration_discovery_shows_user_form(hass):
  1471. """A device found by background discovery advances to the user setup form."""
  1472. result = await hass.config_entries.flow.async_init(
  1473. DOMAIN,
  1474. context={"source": "integration_discovery"},
  1475. data={
  1476. CONF_DEVICE_ID: "bfdiscovered000000",
  1477. CONF_HOST: "192.168.1.77",
  1478. "product_id": "keyxyz",
  1479. "version": "3.5",
  1480. },
  1481. )
  1482. assert result["type"] == "form"
  1483. assert result["step_id"] == "user"
  1484. @pytest.mark.asyncio
  1485. async def test_flow_integration_discovery_aborts_if_configured(hass):
  1486. """A discovered device that is already configured (or ignored) aborts."""
  1487. entry = MockConfigEntry(domain=DOMAIN, unique_id="bfdiscovered000000")
  1488. entry.add_to_hass(hass)
  1489. result = await hass.config_entries.flow.async_init(
  1490. DOMAIN,
  1491. context={"source": "integration_discovery"},
  1492. data={
  1493. CONF_DEVICE_ID: "bfdiscovered000000",
  1494. CONF_HOST: "192.168.1.77",
  1495. },
  1496. )
  1497. assert result["type"] == "abort"
  1498. assert result["reason"] == "already_configured"