test_config_flow.py 52 KB

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