Fixed device key retrieval issue for Midea (#177766)

This commit is contained in:
Simone Chemelli
2026-08-05 11:54:34 +02:00
committed by Bram Kragten
parent 4b35e289a8
commit bbff34fbdd
2 changed files with 329 additions and 51 deletions
+71 -27
View File
@@ -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)
+258 -24
View File
@@ -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,
),
):