Bump midea-local to 11.0.1 (#181405)

This commit is contained in:
Simone Chemelli
2026-09-11 18:21:25 +00:00
committed by Franck Nijhof
parent aad3e6209c
commit 717e4a9bcb
9 changed files with 528 additions and 117 deletions
+23 -54
View File
@@ -251,18 +251,15 @@ class MideaClimate(MideaEntity, ClimateEntity):
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
mode = None
if hvac_mode:
if hvac_mode not in self.hvac_modes:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_hvac_mode",
translation_placeholders={"hvac_mode": hvac_mode},
)
mode = self.hvac_modes.index(hvac_mode)
self._device.set_target_temperature(
if hvac_mode and hvac_mode not in self.hvac_modes:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="unsupported_hvac_mode",
translation_placeholders={"hvac_mode": hvac_mode},
)
self._device.set_raw_target_temperature(
target_temperature=temperature,
mode=mode,
hvac_mode=hvac_mode,
zone=self._zone,
)
@@ -355,31 +352,13 @@ class MideaACClimate(MideaClimate):
@property
@override
def hvac_modes(self) -> list[HVACMode]:
"""Midea AC Climate hvac modes."""
return (
[HVACMode.OFF]
+ (
[HVACMode.AUTO]
if self._device.capabilities.get("auto_mode", True)
else []
)
+ (
[HVACMode.COOL]
if self._device.capabilities.get("cool_mode", True)
else []
)
+ (
[HVACMode.DRY]
if self._device.capabilities.get("dry_mode", True)
else []
)
+ (
[HVACMode.HEAT]
if self._device.capabilities.get("heat_mode", True)
else []
)
+ [HVACMode.FAN_ONLY]
)
"""Midea AC Climate hvac modes.
The device reports the generic mode names in protocol-index order
(``off`` first, ``fan_only`` last), already filtered by its B5
capabilities.
"""
return [HVACMode(mode) for mode in self._device.raw_hvac_modes]
@property
@override
@@ -439,13 +418,7 @@ class MideaACClimate(MideaClimate):
@override
def set_swing_mode(self, swing_mode: str) -> None:
"""Midea AC Climate set swing mode."""
swing_vertical, swing_horizontal = _SWING_MODE_MAP.get(
swing_mode, (False, False)
)
self._device.set_swing(
swing_vertical=swing_vertical,
swing_horizontal=swing_horizontal,
)
self._device.set_raw_swing_mode(swing_mode)
class MideaCCClimate(MideaClimate):
@@ -481,16 +454,13 @@ class MideaCCClimate(MideaClimate):
@override
def fan_modes(self) -> list[str] | None:
"""Midea CC Climate fan modes."""
return self._device.fan_modes
return list(self._device.raw_fan_modes)
@property
@override
def fan_mode(self) -> str | None:
"""Midea CC Climate fan mode."""
fan_mode = self._device.get_attribute(CCAttributes.fan_speed)
if not isinstance(fan_mode, str):
return None
return fan_mode
return self._device.raw_fan_mode
@property
@override
@@ -510,7 +480,7 @@ class MideaCCClimate(MideaClimate):
@override
def set_fan_mode(self, fan_mode: str) -> None:
"""Midea CC Climate set fan mode."""
self._device.set_attribute(attr=CCAttributes.fan_speed, value=fan_mode)
self._device.set_raw_fan_mode(fan_mode)
@override
def set_swing_mode(self, swing_mode: str) -> None:
@@ -554,10 +524,9 @@ class MideaCFClimate(MideaClimate):
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
target_temperature = self.target_temperature or self.min_temp
self._device.set_target_temperature(
target_temperature=target_temperature,
mode=self._hvac_to_protocol_mode(hvac_mode),
self._device.set_raw_target_temperature(
target_temperature=self.target_temperature or self.min_temp,
hvac_mode=hvac_mode,
)
@property
@@ -709,7 +678,7 @@ class MideaC3Climate(MideaClimate):
if hvac_mode == HVACMode.OFF:
self.turn_off()
else:
self._device.set_mode(self._zone, self._hvac_to_protocol_mode(hvac_mode))
self._device.set_raw_hvac_mode(hvac_mode, zone=self._zone)
class MideaFBClimate(MideaClimate):
+79 -18
View File
@@ -14,6 +14,7 @@ from midealocal.const import DeviceType, ProtocolVersion
from midealocal.device import MideaDevice
from midealocal.devices import device_selector
from midealocal.discover import discover
from midealocal.exceptions import MideaCloudError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -110,6 +111,8 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
self.supports: dict = {}
self.cloud: MideaCloud | None = None
self._login_data: dict[str, str] | None = None
self._cloud_error: str | None = None
self._cloud_error_code: int | None = None
unsorted = dict(MIDEA_DEVICE_NAMES)
# sort and assign supports
@@ -125,10 +128,33 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
self.preset_cloud_name: str = preset_account["cloud_name"]
def _clear_login_state(self) -> None:
"""Clear flow-scoped credentials and cloud."""
"""Clear flow-scoped credentials and cloud.
The pending cloud error/code are intentionally left in place: this is
called right before re-showing a form that still needs to render that
error. They are refreshed on the next cloud call (``_check_cloud_login``
resets them) or when ``async_step_auto`` re-runs with input.
"""
self._login_data = None
self.cloud = None
def _reset_cloud_error(self) -> None:
"""Forget any cloud error carried over from a previous submit."""
self._cloud_error = None
self._cloud_error_code = None
def _form_error(self, error: str | None) -> dict[str, Any]:
"""Build async_show_form error kwargs, adding the cloud error code if known."""
if not error:
return {"errors": None}
result: dict[str, Any] = {"errors": {"base": error}}
# only the cloud error slug carries a numeric code to show alongside it
if error == self._cloud_error and self._cloud_error_code is not None:
result["description_placeholders"] = {
"error_code": str(self._cloud_error_code)
}
return result
def _already_configured(self, device_id: str, ip_address: str) -> bool:
"""Check device from json with device_id or ip address."""
for entry in self._async_current_entries():
@@ -195,7 +221,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
cloud_server_options,
default_server,
user_input=user_input,
error="login_failed",
error=self._cloud_error or "login_failed",
)
# user not login, show login form in UI
return self._show_login_credentials_form(
@@ -232,7 +258,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="login_credentials",
data_schema=schema,
errors={"base": error} if error else None,
**self._form_error(error),
)
async def async_step_auth_method(
@@ -259,7 +285,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
)
return await self.async_step_auth_method(
error="preset_login_failed",
error=self._cloud_error or "preset_login_failed",
)
return self.async_show_form(
@@ -280,7 +306,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
),
}
),
errors={"base": error} if error else None,
**self._form_error(error),
)
async def async_step_list(
@@ -383,8 +409,16 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
account,
password,
)
self._reset_cloud_error()
# check cloud login after self.cloud exist
if await self.cloud.login():
try:
logged_in = await self.cloud.login()
except MideaCloudError as err:
LOGGER.debug("Cloud login to %s failed: %s", cloud_name, err)
self._cloud_error = err.translation_key
self._cloud_error_code = err.code
return False
if logged_in:
LOGGER.debug(
"Cloud login succeeded for %s",
cloud_name,
@@ -408,7 +442,20 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
assert self.cloud is not None
# get device token/key from cloud, plus the well-known default keys
keys = await self.cloud.get_cloud_keys(appliance_id)
try:
keys = await self.cloud.get_cloud_keys(appliance_id)
except MideaCloudError as err:
# A cloud rejection (e.g. code 3201) is not fatal: a V3 device may
# still authenticate with a built-in default key. Remember the error
# for display and keep going with the default keys only.
LOGGER.debug(
"Cloud rejected the token request for device %s: %s",
appliance_id,
err,
)
self._cloud_error = err.translation_key
self._cloud_error_code = err.code
keys = {}
if default_key:
keys = {**keys, **(await MideaCloud.get_default_keys())}
error = "connect_error"
@@ -445,7 +492,10 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.debug(
"Unable to connect device with all the token/key",
)
return {"error": error}
result: dict[str, Any] = {"error": error}
if self._cloud_error is not None:
result["cloud_error"] = self._cloud_error
return result
async def async_step_auto(
self,
@@ -455,6 +505,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Discovery device detail info."""
# input device exist
if user_input is not None:
self._reset_cloud_error()
device_id = user_input[CONF_DEVICE]
device = self.devices[device_id]
self.found_device = {
@@ -484,6 +535,10 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
# phase 1, try with user input login data
keys = await self._check_key_from_cloud(device_id)
# _check_key_from_cloud sets the pending cloud error/code on a
# cloud rejection; keep phase 1's in case phase 2 is less specific
phase1_error = self._cloud_error
phase1_error_code = self._cloud_error_code
# no available key, continue the phase 2
if not keys.get("token") or not keys.get("key"):
@@ -494,10 +549,14 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
# get key phase 2: reinit cloud with preset account
if not await self._check_cloud_login(force_login=True):
# _check_cloud_login clears the pending error; if it only
# returned False (no raise), fall back to phase 1's error.
if not self._cloud_error and phase1_error:
self._cloud_error = phase1_error
self._cloud_error_code = phase1_error_code
error = self._cloud_error or "preset_login_failed"
self._clear_login_state()
return await self.async_step_auto(
error="preset_login_failed",
)
return await self.async_step_auto(error=error)
# try to get a passed key, without default_key
keys = await self._check_key_from_cloud(
device_id,
@@ -510,10 +569,12 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
"Can't get available token from Midea server for device %s",
device_id,
)
if not self._cloud_error and phase1_error:
self._cloud_error = phase1_error
self._cloud_error_code = phase1_error_code
error = self._cloud_error or "token_unavailable"
self._clear_login_state()
return await self.async_step_auto(
error="token_unavailable",
)
return await self.async_step_auto(error=error)
# get key pass
self.found_device[CONF_TOKEN] = keys["token"]
self.found_device[CONF_KEY] = keys["key"]
@@ -537,7 +598,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
): vol.In(self.available_device),
},
),
errors={"base": error} if error else None,
**self._form_error(error),
)
def _found_device_to_user_input(self) -> dict[str, Any]:
@@ -676,7 +737,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
if not result:
return self._show_manually_form(
user_input,
error="preset_login_failed",
error=self._cloud_error or "preset_login_failed",
)
# try to get a passed key
keys = await self._check_key_from_cloud(int(user_input[CONF_DEVICE_ID]))
@@ -689,7 +750,7 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self._show_manually_form(
user_input,
error="token_unavailable",
error=keys.get("cloud_error") or "token_unavailable",
)
# set token/key from preset account
@@ -770,5 +831,5 @@ class MideaConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="manually",
data_schema=schema,
errors={"base": error} if error else None,
**self._form_error(error),
)
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["midealocal"],
"quality_scale": "bronze",
"requirements": ["midea-local==10.1.0"]
"requirements": ["midea-local==11.0.1"]
}
@@ -4,7 +4,13 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"account_locked": "Too many failed sign-in attempts. Wait about five minutes before trying again. (err {error_code})",
"cloud_error": "The Midea cloud returned an error. Check the logs for details and try again. (err {error_code})",
"cloud_session_expired": "The Midea cloud sign-in session is no longer valid. Try again. (err {error_code})",
"device_auth_failed": "Could not connect with the provided configuration",
"device_not_registered": "This Midea cloud account is not authorized for this device. The device is registered to a different account. (err {error_code})",
"invalid_auth": "Invalid authentication (err {error_code})",
"invalid_cloud_server": "This account is not registered on the selected cloud server. Choose the server where the account was created. (err {error_code})",
"invalid_device_id_for_ip": "The device ID does not match the selected IP address",
"invalid_device_ip": "Could not find a supported device at this IP address",
"invalid_token": "Token and key must be valid hexadecimal strings",
@@ -14,6 +20,7 @@
"preset_login_failed": "Could not log in with the preset account",
"protocol_mismatch": "The protocol does not match the discovered device",
"token_unavailable": "Could not get a valid token and key from the cloud",
"too_many_logged_in_devices": "The Midea cloud account has too many active sign-ins. Sign out of the Midea app on other devices and try again. (err {error_code})",
"type_mismatch": "The type does not match the discovered device"
},
"step": {
+1 -1
View File
@@ -1611,7 +1611,7 @@ micloud==0.5
microBeesPy==0.3.5
# homeassistant.components.midea
midea-local==10.1.0
midea-local==11.0.1
# homeassistant.components.mill
mill-local==0.5.0
+15 -10
View File
@@ -46,11 +46,12 @@ class DummyDevice:
self.subtype = TEST_SUBTYPE
self.available = False
self.attributes = attributes or {}
self.capabilities: dict[str, Any] = {}
self._callbacks: list[Callable] = []
self.calls: list[tuple] = []
self.temperature_step = 1
self.fan_modes = ["Low", "Medium", "High", "Auto"]
self.raw_hvac_modes = ["off", "auto", "cool", "dry", "heat", "fan_only"]
self.raw_fan_modes = ["low", "medium", "high", "auto"]
self.raw_fan_mode: str | None = "high"
self.modes = [
"Auto",
"ECO",
@@ -88,17 +89,21 @@ class DummyDevice:
self.notify_update({attr: value})
self.calls.append(("set_attribute", attr, value))
def set_target_temperature(self, **kwargs: Any) -> None:
def set_raw_target_temperature(self, **kwargs: Any) -> None:
"""Record set target temperature call."""
self.calls.append(("set_target_temperature", kwargs))
self.calls.append(("set_raw_target_temperature", kwargs))
def set_swing(self, **kwargs: Any) -> None:
"""Record set swing call."""
self.calls.append(("set_swing", kwargs))
def set_raw_swing_mode(self, swing_mode: str) -> None:
"""Record set swing mode call."""
self.calls.append(("set_raw_swing_mode", swing_mode))
def set_mode(self, zone: int, mode: int) -> None:
"""Record set mode call."""
self.calls.append(("set_mode", zone, mode))
def set_raw_fan_mode(self, fan_mode: str) -> None:
"""Record set fan mode call."""
self.calls.append(("set_raw_fan_mode", fan_mode))
def set_raw_hvac_mode(self, hvac_mode: str, zone: int | None = None) -> None:
"""Record set hvac mode call."""
self.calls.append(("set_raw_hvac_mode", hvac_mode, zone))
def start_work(self) -> None:
"""Record start_work call."""
@@ -275,10 +275,10 @@
'area_id': None,
'capabilities': dict({
<ClimateEntityCapabilityAttribute.FAN_MODES: 'fan_modes'>: list([
'Low',
'Medium',
'High',
'Auto',
'low',
'medium',
'high',
'auto',
]),
<ClimateEntityCapabilityAttribute.HVAC_MODES: 'hvac_modes'>: list([
<HVACMode.OFF: 'off'>,
@@ -335,12 +335,12 @@
StateSnapshot({
'attributes': ReadOnlyDict({
<ClimateEntityStateAttribute.CURRENT_TEMPERATURE: 'current_temperature'>: None,
<ClimateEntityStateAttribute.FAN_MODE: 'fan_mode'>: 'High',
<ClimateEntityStateAttribute.FAN_MODE: 'fan_mode'>: 'high',
<ClimateEntityCapabilityAttribute.FAN_MODES: 'fan_modes'>: list([
'Low',
'Medium',
'High',
'Auto',
'low',
'medium',
'high',
'auto',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'MDV Wi-Fi Controller',
<ClimateEntityCapabilityAttribute.HVAC_MODES: 'hvac_modes'>: list([
+37 -23
View File
@@ -147,8 +147,12 @@ async def test_midea_ac_climate_setup_and_services(
{ATTR_TEMPERATURE: 23.1, "hvac_mode": HVACMode.COOL},
[
(
"set_target_temperature",
{"target_temperature": 23.1, "mode": 2, "zone": None},
"set_raw_target_temperature",
{
"target_temperature": 23.1,
"hvac_mode": HVACMode.COOL,
"zone": None,
},
)
],
device,
@@ -174,7 +178,7 @@ async def test_midea_ac_climate_setup_and_services(
entity_entry.entity_id,
SERVICE_SET_SWING_MODE,
{ATTR_SWING_MODE: SWING_VERTICAL},
[("set_swing", {"swing_vertical": True, "swing_horizontal": False})],
[("set_raw_swing_mode", SWING_VERTICAL)],
device,
)
await _assert_service_calls(
@@ -260,7 +264,6 @@ async def test_midea_cc_climate_setup_and_services(
attributes={
CCAttributes.power: True,
CCAttributes.mode: 5,
CCAttributes.fan_speed: "High",
CCAttributes.temperature_precision: 0.5,
CCAttributes.swing: True,
},
@@ -272,7 +275,7 @@ async def test_midea_cc_climate_setup_and_services(
state = hass.states.get(entity_entry.entity_id)
assert state is not None
assert state.state == HVACMode.AUTO
assert state.attributes[ATTR_FAN_MODE] == "High"
assert state.attributes[ATTR_FAN_MODE] == "high"
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.OFF,
HVACMode.FAN_ONLY,
@@ -289,8 +292,8 @@ async def test_midea_cc_climate_setup_and_services(
hass,
entity_entry.entity_id,
SERVICE_SET_FAN_MODE,
{ATTR_FAN_MODE: "Low"},
[("set_attribute", CCAttributes.fan_speed, "Low")],
{ATTR_FAN_MODE: "low"},
[("set_raw_fan_mode", "low")],
device,
)
await _assert_service_calls(
@@ -352,8 +355,8 @@ async def test_midea_cf_climate_setup_and_services(
{"hvac_mode": HVACMode.HEAT},
[
(
"set_target_temperature",
{"target_temperature": 20.0, "mode": 3},
"set_raw_target_temperature",
{"target_temperature": 20.0, "hvac_mode": HVACMode.HEAT},
)
],
device,
@@ -408,8 +411,12 @@ async def test_midea_c3_climate_setup_and_services(
{ATTR_TEMPERATURE: 21.4, "hvac_mode": HVACMode.COOL},
[
(
"set_target_temperature",
{"target_temperature": 21.4, "mode": 2, "zone": 0},
"set_raw_target_temperature",
{
"target_temperature": 21.4,
"hvac_mode": HVACMode.COOL,
"zone": 0,
},
)
],
device,
@@ -427,7 +434,7 @@ async def test_midea_c3_climate_setup_and_services(
zone1.entity_id,
SERVICE_SET_HVAC_MODE,
{"hvac_mode": HVACMode.HEAT},
[("set_mode", 0, 3)],
[("set_raw_hvac_mode", HVACMode.HEAT, 0)],
device,
)
@@ -597,8 +604,8 @@ async def test_ac_set_temperature_without_hvac_mode(
{ATTR_TEMPERATURE: 23.0},
[
(
"set_target_temperature",
{"target_temperature": 23.0, "mode": None, "zone": None},
"set_raw_target_temperature",
{"target_temperature": 23.0, "hvac_mode": None, "zone": None},
)
],
device,
@@ -1133,8 +1140,8 @@ async def test_cf_set_hvac_mode_falls_back_to_min_temp(
{"hvac_mode": HVACMode.HEAT},
[
(
"set_target_temperature",
{"target_temperature": 16.0, "mode": 3},
"set_raw_target_temperature",
{"target_temperature": 16.0, "hvac_mode": HVACMode.HEAT},
)
],
device,
@@ -1193,8 +1200,12 @@ async def test_fb_set_temperature_with_heat_mode_turns_on_when_off(
[
("set_attribute", FBAttributes.power, True),
(
"set_target_temperature",
{"target_temperature": 25.0, "mode": 1, "zone": None},
"set_raw_target_temperature",
{
"target_temperature": 25.0,
"hvac_mode": HVACMode.HEAT,
"zone": None,
},
),
],
device,
@@ -1258,11 +1269,11 @@ async def test_cc_fan_and_swing_invalid_types_return_none(
attributes={
CCAttributes.power: True,
CCAttributes.mode: 5,
CCAttributes.fan_speed: 1,
CCAttributes.temperature_precision: 0.5,
CCAttributes.swing: "on",
},
)
device.raw_fan_mode = None
config_entry = mock_config_entry(device)
await setup_integration(hass, config_entry, device)
entity_entry = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_climate"]
@@ -1302,8 +1313,12 @@ async def test_c3_zone2_service_calls_address_zone_two(
{ATTR_TEMPERATURE: 24.0, "hvac_mode": HVACMode.COOL},
[
(
"set_target_temperature",
{"target_temperature": 24.0, "mode": 2, "zone": 1},
"set_raw_target_temperature",
{
"target_temperature": 24.0,
"hvac_mode": HVACMode.COOL,
"zone": 1,
},
)
],
device,
@@ -1313,7 +1328,7 @@ async def test_c3_zone2_service_calls_address_zone_two(
zone2.entity_id,
SERVICE_SET_HVAC_MODE,
{"hvac_mode": HVACMode.HEAT},
[("set_mode", 1, 3)],
[("set_raw_hvac_mode", HVACMode.HEAT, 1)],
device,
)
await _assert_service_calls(
@@ -1402,7 +1417,6 @@ async def test_fb_invalid_attribute_types_return_none(
attributes={
CCAttributes.power: True,
CCAttributes.mode: 5,
CCAttributes.fan_speed: "High",
CCAttributes.temperature_precision: 0.5,
CCAttributes.swing: True,
},
+356 -1
View File
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from midealocal.const import DeviceType, ProtocolVersion
from midealocal.device import MideaDevice
from midealocal.exceptions import CloudLoginError, MideaCloudError, NoDeviceRegistered
import pytest
from homeassistant.components.midea.config_flow import (
@@ -164,6 +165,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None:
"connect_return",
"cloud_login_return",
"cloud_keys_return",
"cloud_keys_side_effect",
"default_keys_return",
"pre_input",
"expected_error",
@@ -175,6 +177,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None:
None,
True,
{},
None,
{},
None,
"invalid_token",
@@ -186,6 +189,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None:
None,
True,
{},
None,
{},
None,
"invalid_device_ip",
@@ -197,6 +201,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None:
None,
True,
{},
None,
{},
None,
"invalid_device_id_for_ip",
@@ -214,6 +219,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None:
None,
True,
{},
None,
{},
None,
"ip_address_mismatch",
@@ -231,6 +237,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None:
None,
True,
{},
None,
{},
None,
"protocol_mismatch",
@@ -247,6 +254,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None:
None,
True,
{},
None,
{},
None,
"type_mismatch",
@@ -258,6 +266,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None:
False,
True,
{},
None,
{},
None,
"device_auth_failed",
@@ -269,6 +278,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None:
None,
False,
{},
None,
{},
None,
"preset_login_failed",
@@ -280,11 +290,24 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None:
None,
True,
{},
None,
{},
None,
"token_unavailable",
id="no_token_from_cloud",
),
pytest.param(
{**EXTENDED_DATA, CONF_TOKEN: "", CONF_KEY: ""},
{TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE}},
None,
True,
{},
NoDeviceRegistered(3201, "no permission"),
{},
None,
"device_not_registered",
id="cloud_rejects_token_request",
),
],
)
async def test_manual_step_errors(
@@ -294,6 +317,7 @@ async def test_manual_step_errors(
connect_return: bool | None,
cloud_login_return: bool,
cloud_keys_return: dict[str, dict[str, str]],
cloud_keys_side_effect: Exception | None,
default_keys_return: dict[str, dict[str, str]],
pre_input: dict[str, object] | None,
expected_error: str,
@@ -318,7 +342,9 @@ async def test_manual_step_errors(
cloud = MagicMock()
cloud.login = AsyncMock(return_value=cloud_login_return)
cloud.get_cloud_keys = AsyncMock(return_value=cloud_keys_return)
cloud.get_cloud_keys = AsyncMock(
return_value=cloud_keys_return, side_effect=cloud_keys_side_effect
)
with (
patch(
@@ -697,6 +723,150 @@ async def test_auto_flow_v3_preset_phase1_default_key_success(
assert result["data"][CONF_KEY] == TEST_KEY
async def test_auto_flow_v3_default_key_success_after_cloud_error(
hass: HomeAssistant,
) -> None:
"""Test a cloud rejection still lets a device connect with a built-in key.
``get_cloud_keys`` now raises for known failure codes; that must not skip
the established well-known default-key fallback.
"""
mock_devices = {TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE}}
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": "search"},
)
with patch(
"homeassistant.components.midea.config_flow.discover",
return_value=mock_devices,
):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={CONF_IP_ADDRESS: "auto"},
)
assert result["step_id"] == "auto"
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={CONF_DEVICE: TEST_DEVICE_ID},
)
assert result["step_id"] == "auth_method"
cloud = MagicMock()
cloud.login = AsyncMock(return_value=True)
cloud.get_device_info = AsyncMock(return_value=None)
cloud.get_cloud_keys = AsyncMock(
side_effect=NoDeviceRegistered(3201, "no permission")
)
dm = MagicMock()
dm.connect.return_value = True
with (
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={"builtin": {"token": TEST_TOKEN, "key": TEST_KEY}}),
),
patch(
"homeassistant.components.midea.config_flow.device_selector",
return_value=dm,
),
):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={"login_mode": LOGIN_MODE_PRESET},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TOKEN] == TEST_TOKEN
assert result["data"][CONF_KEY] == TEST_KEY
async def test_auto_flow_phase2_login_false_keeps_phase1_cloud_error(
hass: HomeAssistant,
) -> None:
"""Test a specific phase-1 cloud error survives a plain phase-2 login failure.
Phase 1 raises ``NoDeviceRegistered`` (3201); phase 2's preset login only
returns ``False`` (no exception), which resets the pending error - the flow
must still report the actionable 3201 error, not ``preset_login_failed``.
"""
mock_devices = {TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE}}
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": "search"},
)
with patch(
"homeassistant.components.midea.config_flow.discover",
return_value=mock_devices,
):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={CONF_IP_ADDRESS: "auto"},
)
assert result["step_id"] == "auto"
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={CONF_DEVICE: TEST_DEVICE_ID},
)
assert result["step_id"] == "auth_method"
cloud = MagicMock()
# phase 1 login succeeds, phase 2 (force_login) login just returns False
cloud.login = AsyncMock(side_effect=[True, False])
cloud.get_device_info = AsyncMock(return_value=None)
cloud.get_cloud_keys = AsyncMock(
side_effect=NoDeviceRegistered(3201, "no permission")
)
with (
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={}),
),
):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={"login_mode": LOGIN_MODE_PRESET},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auto"
assert result["errors"] == {"base": "device_not_registered"}
assert result["description_placeholders"] == {"error_code": "3201"}
async def test_auto_flow_v3_token_retrieval_exhausted(hass: HomeAssistant) -> None:
"""Test both phase 1 and phase 2 key retrieval failing surfaces token_unavailable."""
mock_devices = {TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE}}
@@ -1248,6 +1418,191 @@ async def test_login_credentials_step_login_failed_sets_error(
assert get_schema_suggested_value(data_schema, CONF_SERVER) == DEFAULT_CLOUD
@pytest.mark.parametrize(
("login_error", "expected_error", "expected_code"),
[
(CloudLoginError(7610, "locked"), "account_locked", "7610"),
(MideaCloudError(9999, "system error"), "cloud_error", "9999"),
],
ids=["specific_login_error", "generic_cloud_error"],
)
async def test_login_credentials_step_maps_cloud_error(
hass: HomeAssistant,
login_error: MideaCloudError,
expected_error: str,
expected_code: str,
) -> None:
"""Test a cloud error raised by login() surfaces its translation_key and code.
The exhaustive code-to-slug matrix is covered in the midea-local library
tests; here we only check the config flow forwards ``err.translation_key``
and the numeric code.
"""
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": "search"},
)
with patch(
"homeassistant.components.midea.config_flow.discover",
return_value=DISCOVERY_RESULT,
):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={CONF_IP_ADDRESS: "auto"},
)
assert result["step_id"] == "auto"
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={CONF_DEVICE: TEST_DEVICE_ID},
)
assert result["step_id"] == "auth_method"
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={"login_mode": LOGIN_MODE_ACCOUNT},
)
assert result["step_id"] == "login_credentials"
cloud = MagicMock()
cloud.login = AsyncMock(side_effect=login_error)
with (
patch(
"homeassistant.components.midea.config_flow.MideaCloud.get_cloud_servers",
AsyncMock(return_value={1: DEFAULT_CLOUD}),
),
patch(
"homeassistant.components.midea.config_flow.async_get_clientsession",
return_value=object(),
),
patch(
"homeassistant.components.midea.config_flow.get_midea_cloud",
return_value=cloud,
),
):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={
CONF_SERVER: DEFAULT_CLOUD,
CONF_ACCOUNT: "user",
CONF_PASSWORD: "pass",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "login_credentials"
assert result["errors"] == {"base": expected_error}
assert result["description_placeholders"] == {"error_code": expected_code}
@pytest.mark.parametrize(
(
"login_side_effect",
"cloud_keys_side_effect",
"expected_step",
"expected_error",
"expected_code",
),
[
pytest.param(
CloudLoginError(7610, "locked"),
None,
"auth_method",
"account_locked",
"7610",
id="preset_login_rejected",
),
pytest.param(
None,
[NoDeviceRegistered(3201, "no permission"), {}],
"auto",
"device_not_registered",
"3201",
id="device_bound_to_other_account",
),
],
)
async def test_auto_flow_preset_auth_maps_cloud_error(
hass: HomeAssistant,
login_side_effect: MideaCloudError | None,
cloud_keys_side_effect: list[object] | None,
expected_step: str,
expected_error: str,
expected_code: str,
) -> None:
"""Test cloud API errors on the preset auth path surface a specific message.
Either the preset login itself is rejected (stays on auth_method), or the
login succeeds but the cloud refuses to issue a token/key for the device
(falls back to the auto step).
"""
mock_devices = {TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE}}
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": "search"},
)
with patch(
"homeassistant.components.midea.config_flow.discover",
return_value=mock_devices,
):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={CONF_IP_ADDRESS: "auto"},
)
assert result["step_id"] == "auto"
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={CONF_DEVICE: TEST_DEVICE_ID},
)
assert result["step_id"] == "auth_method"
cloud = MagicMock()
cloud.login = AsyncMock(return_value=True, side_effect=login_side_effect)
cloud.get_device_info = AsyncMock(return_value=None)
cloud.get_cloud_keys = AsyncMock(
return_value={}, side_effect=cloud_keys_side_effect
)
with (
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={}),
),
):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={"login_mode": LOGIN_MODE_PRESET},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == expected_step
assert result["errors"] == {"base": expected_error}
assert result["description_placeholders"] == {"error_code": expected_code}
async def test_login_credentials_step_recovers_after_failed_login(
hass: HomeAssistant,
) -> None: