diff --git a/homeassistant/components/midea/config_flow.py b/homeassistant/components/midea/config_flow.py index 3028905f78ba..154524d38dd7 100644 --- a/homeassistant/components/midea/config_flow.py +++ b/homeassistant/components/midea/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Midea.""" +from functools import partial from operator import itemgetter from typing import Any, override @@ -11,6 +12,7 @@ from midealocal.cloud import ( ) from midealocal.const import DeviceType, ProtocolVersion from midealocal.device import MideaDevice +from midealocal.devices import device_selector from midealocal.discover import discover import voluptuous as vol @@ -47,6 +49,40 @@ def _connect_and_close(dm: MideaDevice) -> bool: dm.close_socket() +def _select_and_connect( + *, + device_id: int, + device_type: int, + ip_address: str, + port: int, + token: str, + key: str, + device_protocol: ProtocolVersion, + model: str, + subtype: int, +) -> bool | None: + """Select the device implementation and connect to it in a single executor job. + + Returns None if there is no device implementation for device_type. + """ + dm = device_selector( + "", + device_id, + device_type, + ip_address, + port, + token, + key, + device_protocol, + model, + subtype, + "", + ) + if dm is None: + return None + return _connect_and_close(dm) + + class MideaConfigFlow(ConfigFlow, domain=DOMAIN): """Define current integration setup steps. @@ -366,22 +402,30 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN): keys = await self.cloud.get_cloud_keys(appliance_id) if default_key: keys = {**keys, **(await MideaCloud.get_default_keys())} + error = "connect_error" # use token/key to connect device and confirm token result for k, value in keys.items(): - dm = MideaDevice( - name="", - device_id=appliance_id, - device_type=device.get(CONF_TYPE), - ip_address=device.get(CONF_IP_ADDRESS), - port=device.get(CONF_PORT), - token=value["token"], - key=value["key"], - device_protocol=ProtocolVersion.V3, - model=device.get(CONF_MODEL), - subtype=device.get(CONF_SUBTYPE, 0), - attributes={}, + connected = await self.hass.async_add_executor_job( + partial( + _select_and_connect, + device_id=appliance_id, + device_type=device.get(CONF_TYPE), + ip_address=device.get(CONF_IP_ADDRESS), + port=device.get(CONF_PORT), + token=value["token"], + key=value["key"], + device_protocol=ProtocolVersion.V3, + model=device.get(CONF_MODEL), + subtype=device.get(CONF_SUBTYPE, 0), + ), ) - connected = await self.hass.async_add_executor_job(_connect_and_close, dm) + if connected is None: + LOGGER.debug( + "No device implementation for device_type %s", + device.get(CONF_TYPE), + ) + error = "no_device_implementation" + break if connected: return value # return debug log with failed key @@ -392,7 +436,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN): LOGGER.debug( "Unable to connect device with all the token/key", ) - return {"error": "connect_error"} + return {"error": error} async def async_step_auto( self, @@ -512,20 +556,20 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(str(device_id)) self._abort_if_unique_id_configured() - dm = MideaDevice( - name="", - device_id=device_id, - device_type=user_input[CONF_TYPE], - ip_address=user_input[CONF_IP_ADDRESS], - port=user_input[CONF_PORT], - token=user_input[CONF_TOKEN], - key=user_input[CONF_KEY], - device_protocol=user_input[CONF_PROTOCOL], - model=user_input[CONF_MODEL], - subtype=user_input[CONF_SUBTYPE], - attributes={}, + connected = await self.hass.async_add_executor_job( + partial( + _select_and_connect, + device_id=device_id, + device_type=user_input[CONF_TYPE], + ip_address=user_input[CONF_IP_ADDRESS], + port=user_input[CONF_PORT], + token=user_input[CONF_TOKEN], + key=user_input[CONF_KEY], + device_protocol=user_input[CONF_PROTOCOL], + model=user_input[CONF_MODEL], + subtype=user_input[CONF_SUBTYPE], + ), ) - connected = await self.hass.async_add_executor_job(_connect_and_close, dm) if connected: device_type = user_input[CONF_TYPE] found_name = self.found_device.get(CONF_NAME) diff --git a/tests/components/midea/test_config_flow.py b/tests/components/midea/test_config_flow.py index a960817353da..c1468e76643a 100644 --- a/tests/components/midea/test_config_flow.py +++ b/tests/components/midea/test_config_flow.py @@ -1,14 +1,17 @@ """Tests for the Midea config flow.""" +from functools import partial from unittest.mock import AsyncMock, MagicMock, patch from midealocal.const import DeviceType, ProtocolVersion +from midealocal.device import MideaDevice import pytest from homeassistant.components.midea.config_flow import ( DEFAULT_CLOUD, LOGIN_MODE_ACCOUNT, LOGIN_MODE_PRESET, + _select_and_connect, ) from homeassistant.components.midea.const import ( CONF_ACCOUNT, @@ -69,12 +72,12 @@ async def test_manual_flow_success(hass: HomeAssistant) -> None: return_value=DISCOVERY_RESULT, ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", - ) as mock_midea_device, + "homeassistant.components.midea.config_flow.device_selector", + ) as mock_device_selector, ): mock_device = MagicMock() mock_device.connect.return_value = True - mock_midea_device.return_value = mock_device + mock_device_selector.return_value = mock_device result = await hass.config_entries.flow.async_configure( flow_id, @@ -128,12 +131,12 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None: return_value=DISCOVERY_RESULT, ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", - ) as mock_midea_device, + "homeassistant.components.midea.config_flow.device_selector", + ) as mock_device_selector, ): mock_device = MagicMock() mock_device.connect.return_value = True - mock_midea_device.return_value = mock_device + mock_device_selector.return_value = mock_device await hass.config_entries.flow.async_configure( flow_id, @@ -317,7 +320,7 @@ async def test_manual_step_errors( return_value=discover_result, ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", + "homeassistant.components.midea.config_flow.device_selector", return_value=dm, ), patch( @@ -416,7 +419,7 @@ async def test_manual_step_retries_discovery_after_mismatch( ], ) as mock_discover, patch( - "homeassistant.components.midea.config_flow.MideaDevice", + "homeassistant.components.midea.config_flow.device_selector", return_value=dm, ), ): @@ -532,7 +535,7 @@ async def test_auto_flow_cloud_device_info_overrides_name_and_subtype( AsyncMock(return_value={}), ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", + "homeassistant.components.midea.config_flow.device_selector", return_value=dm, ), ): @@ -604,7 +607,7 @@ async def test_auto_flow_v3_preset_phase1_cloud_keys_success( AsyncMock(return_value={}), ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", + "homeassistant.components.midea.config_flow.device_selector", return_value=dm, ), ): @@ -673,7 +676,7 @@ async def test_auto_flow_v3_preset_phase1_default_key_success( AsyncMock(return_value={"builtin": {"token": TEST_TOKEN, "key": TEST_KEY}}), ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", + "homeassistant.components.midea.config_flow.device_selector", return_value=dm, ), ): @@ -748,7 +751,7 @@ async def test_auto_flow_v3_token_retrieval_exhausted(hass: HomeAssistant) -> No AsyncMock(return_value={"builtin": {"token": TEST_TOKEN, "key": TEST_KEY}}), ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", + "homeassistant.components.midea.config_flow.device_selector", return_value=dm, ), ): @@ -949,7 +952,7 @@ async def test_auto_flow_v3_phase2_success_after_phase1_failure( AsyncMock(return_value={}), ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", + "homeassistant.components.midea.config_flow.device_selector", return_value=dm, ), ): @@ -1079,7 +1082,7 @@ async def test_auto_flow_v1_v2_success_when_cloud_down( return_value=mock_devices, ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", + "homeassistant.components.midea.config_flow.device_selector", return_value=dm, ), patch( @@ -1307,7 +1310,7 @@ async def test_login_credentials_step_recovers_after_failed_login( AsyncMock(return_value={}), ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", + "homeassistant.components.midea.config_flow.device_selector", return_value=dm, ), ): @@ -1474,10 +1477,10 @@ async def test_manual_step_v3_missing_token_key_sets_retrieved_values( AsyncMock(return_value={}), ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", - ) as mock_midea_device, + "homeassistant.components.midea.config_flow.device_selector", + ) as mock_device_selector, ): - mock_midea_device.return_value = dm + mock_device_selector.return_value = dm result = await hass.config_entries.flow.async_configure( flow_id, user_input=user_input, @@ -1487,8 +1490,88 @@ async def test_manual_step_v3_missing_token_key_sets_retrieved_values( assert result["step_id"] == "manually" assert result["errors"] == {"base": "device_auth_failed"} - assert mock_midea_device.call_args.kwargs["token"] == TEST_TOKEN - assert mock_midea_device.call_args.kwargs["key"] == TEST_KEY + # _select_and_connect() calls device_selector() positionally (name, + # device_id, device_type, ip_address, port, token, key, ...); it is itself + # submitted to hass.async_add_executor_job via functools.partial so device + # selection and the connection attempt share a single executor job. + assert mock_device_selector.call_args.args[5] == TEST_TOKEN + assert mock_device_selector.call_args.args[6] == TEST_KEY + + +async def test_manual_step_v3_missing_token_key_unsupported_device_type( + hass: HomeAssistant, +) -> None: + """Test cloud key retrieval surfaces token_unavailable when device_selector() finds no module. + + device_selector() returns None when there is no device implementation + module for the given device_type. _check_key_from_cloud() must stop + trying candidate keys and report no usable token, rather than crashing + on a None device. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + flow_id = result["flow_id"] + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={"next_step_id": "manually"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manually" + + device = { + **BASE_DATA, + CONF_TYPE: TEST_TYPE, + CONF_PROTOCOL: ProtocolVersion.V3, + CONF_IP_ADDRESS: TEST_IP_ADDRESS, + CONF_SUBTYPE: TEST_SUBTYPE, + } + user_input: dict[str, object] = { + **EXTENDED_DATA, + CONF_PROTOCOL: ProtocolVersion.V3, + CONF_TOKEN: "", + CONF_KEY: "", + } + + cloud = MagicMock() + cloud.login = AsyncMock(return_value=True) + cloud.get_cloud_keys = AsyncMock( + return_value={"method": {"token": TEST_TOKEN, "key": TEST_KEY}} + ) + + with ( + patch( + "homeassistant.components.midea.config_flow.discover", + return_value={TEST_DEVICE_ID: device}, + ), + patch( + "homeassistant.components.midea.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea.config_flow.MideaCloud.get_default_keys", + AsyncMock(return_value={}), + ), + patch( + "homeassistant.components.midea.config_flow.device_selector", + return_value=None, + ), + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manually" + assert result["errors"] == {"base": "token_unavailable"} async def test_manually_flow_success(hass: HomeAssistant) -> None: @@ -1514,12 +1597,12 @@ async def test_manually_flow_success(hass: HomeAssistant) -> None: return_value=DISCOVERY_RESULT, ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", - ) as mock_midea_device, + "homeassistant.components.midea.config_flow.device_selector", + ) as mock_device_selector, ): mock_device = MagicMock() mock_device.connect.return_value = True - mock_midea_device.return_value = mock_device + mock_device_selector.return_value = mock_device result = await hass.config_entries.flow.async_configure( flow_id, @@ -1544,6 +1627,157 @@ async def test_manually_flow_success(hass: HomeAssistant) -> None: assert result["data"][CONF_KEY] == TEST_KEY +async def test_manually_flow_unsupported_device_type(hass: HomeAssistant) -> None: + """Test entry creation surfaces device_auth_failed when device_selector() finds no module. + + device_selector() returns None when there is no device implementation + module for the given device_type, which must be treated the same as a + failed connection rather than crashing on a None device. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + flow_id = result["flow_id"] + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={"next_step_id": "manually"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manually" + + with ( + patch( + "homeassistant.components.midea.config_flow.discover", + return_value=DISCOVERY_RESULT, + ), + patch( + "homeassistant.components.midea.config_flow.device_selector", + return_value=None, + ), + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={**EXTENDED_DATA}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manually" + assert result["errors"] == {"base": "device_auth_failed"} + + +async def test_manually_flow_builds_concrete_device_subclass( + hass: HomeAssistant, +) -> None: + """Test entry creation builds a concrete device subclass, not the base class. + + Regression test: _async_create_midea_entry previously instantiated the + abstract midealocal.device.MideaDevice directly instead of going through + device_selector(). Its build_query() unconditionally raises + NotImplementedError, so refresh_status() would fail immediately after a + successful authentication for every real device. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, + user_input={"next_step_id": "manually"}, + ) + + with ( + patch( + "homeassistant.components.midea.config_flow.discover", + return_value=DISCOVERY_RESULT, + ), + patch.object(MideaDevice, "connect", autospec=True) as mock_connect, + patch.object(MideaDevice, "close_socket", autospec=True), + ): + mock_connect.return_value = True + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_DEVICE_ID: TEST_DEVICE_ID, + CONF_TYPE: TEST_TYPE, + CONF_IP_ADDRESS: TEST_IP_ADDRESS, + CONF_PORT: TEST_PORT, + CONF_PROTOCOL: TEST_PROTOCOL, + CONF_MODEL: TEST_MODEL, + CONF_SUBTYPE: TEST_SUBTYPE, + CONF_TOKEN: TEST_TOKEN, + CONF_KEY: TEST_KEY, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + dm = mock_connect.call_args.args[0] + assert type(dm) is not MideaDevice + assert isinstance(dm.build_query(), list) + + +async def test_manually_flow_runs_device_selector_in_executor( + hass: HomeAssistant, +) -> None: + """Test device_selector() is dispatched via the executor, not the event loop. + + Regression test: device_selector() calls importlib.import_module() to + dynamically load the concrete device subclass. That is a blocking call, + so it must never run directly on the event loop - Home Assistant's + blocking-call detector flags exactly that. device_selector() is invoked + from within _select_and_connect(), which is what actually gets dispatched + to the executor (wrapped in a functools.partial). + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, + user_input={"next_step_id": "manually"}, + ) + + with ( + patch( + "homeassistant.components.midea.config_flow.discover", + return_value=DISCOVERY_RESULT, + ), + patch.object(MideaDevice, "connect", autospec=True, return_value=True), + patch.object(MideaDevice, "close_socket", autospec=True), + patch.object( + hass, + "async_add_executor_job", + wraps=hass.async_add_executor_job, + ) as mock_executor_job, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_DEVICE_ID: TEST_DEVICE_ID, + CONF_TYPE: TEST_TYPE, + CONF_IP_ADDRESS: TEST_IP_ADDRESS, + CONF_PORT: TEST_PORT, + CONF_PROTOCOL: TEST_PROTOCOL, + CONF_MODEL: TEST_MODEL, + CONF_SUBTYPE: TEST_SUBTYPE, + CONF_TOKEN: TEST_TOKEN, + CONF_KEY: TEST_KEY, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + dispatched_funcs = [call.args[0] for call in mock_executor_job.call_args_list] + assert any( + isinstance(func, partial) and func.func is _select_and_connect + for func in dispatched_funcs + ) + + async def test_login_credentials_step_falls_back_to_default_cloud( hass: HomeAssistant, ) -> None: @@ -1663,7 +1897,7 @@ async def test_login_credentials_step_success_resumes_auto_flow( AsyncMock(return_value={}), ), patch( - "homeassistant.components.midea.config_flow.MideaDevice", + "homeassistant.components.midea.config_flow.device_selector", return_value=dm, ), ):