diff --git a/homeassistant/components/midea/climate.py b/homeassistant/components/midea/climate.py index bd5c94ce0ddbf..1fd0959a81b69 100644 --- a/homeassistant/components/midea/climate.py +++ b/homeassistant/components/midea/climate.py @@ -251,18 +251,15 @@ def set_temperature(self, **kwargs: Any) -> None: 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 @@ def __init__( @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 @@ def set_fan_mode(self, fan_mode: str) -> None: @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 @@ def __init__( @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 @@ def swing_mode(self) -> str | None: @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 @@ def set_hvac_mode(self, hvac_mode: HVACMode) -> None: 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 @@ def set_hvac_mode(self, hvac_mode: HVACMode) -> None: 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): diff --git a/homeassistant/components/midea/config_flow.py b/homeassistant/components/midea/config_flow.py index 61c53c2d1bff4..1c7853da3522b 100644 --- a/homeassistant/components/midea/config_flow.py +++ b/homeassistant/components/midea/config_flow.py @@ -14,6 +14,7 @@ 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 @@ -112,6 +113,8 @@ def __init__(self) -> None: 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 @@ -127,10 +130,33 @@ def __init__(self) -> None: 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(): @@ -197,7 +223,7 @@ async def async_step_login_credentials( 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( @@ -234,7 +260,7 @@ def _show_login_credentials_form( 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( @@ -261,7 +287,7 @@ async def async_step_auth_method( ) return await self.async_step_auth_method( - error="preset_login_failed", + error=self._cloud_error or "preset_login_failed", ) return self.async_show_form( @@ -282,7 +308,7 @@ async def async_step_auth_method( ), } ), - errors={"base": error} if error else None, + **self._form_error(error), ) async def async_step_list( @@ -385,8 +411,16 @@ async def _check_cloud_login( 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, @@ -410,7 +444,20 @@ async def _check_key_from_cloud( 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" @@ -447,7 +494,10 @@ async def _check_key_from_cloud( 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, @@ -457,6 +507,7 @@ async def async_step_auto( """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 = { @@ -486,6 +537,10 @@ async def async_step_auto( # 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"): @@ -496,10 +551,14 @@ async def async_step_auto( # 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, @@ -512,10 +571,12 @@ async def async_step_auto( "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"] @@ -539,7 +600,7 @@ async def async_step_auto( ): 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]: @@ -678,7 +739,7 @@ async def async_step_manually( 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])) @@ -691,7 +752,7 @@ async def async_step_manually( ) return self._show_manually_form( user_input, - error="token_unavailable", + error=keys.get("cloud_error") or "token_unavailable", ) # set token/key from preset account @@ -808,7 +869,7 @@ def _show_manually_form( return self.async_show_form( step_id="manually", data_schema=schema, - errors={"base": error} if error else None, + **self._form_error(error), ) @override diff --git a/homeassistant/components/midea/manifest.json b/homeassistant/components/midea/manifest.json index 7a1353ae49d38..e91c9d4e386bf 100644 --- a/homeassistant/components/midea/manifest.json +++ b/homeassistant/components/midea/manifest.json @@ -13,5 +13,5 @@ "iot_class": "local_polling", "loggers": ["midealocal"], "quality_scale": "bronze", - "requirements": ["midea-local==10.1.0"] + "requirements": ["midea-local==11.0.1"] } diff --git a/homeassistant/components/midea/strings.json b/homeassistant/components/midea/strings.json index 5d2b52ca4eb7d..70877abc82ad2 100644 --- a/homeassistant/components/midea/strings.json +++ b/homeassistant/components/midea/strings.json @@ -5,7 +5,13 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "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", @@ -15,6 +21,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": { diff --git a/requirements_all.txt b/requirements_all.txt index 74285d09ff1c6..45643a444a4f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1620,7 +1620,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 diff --git a/tests/components/midea/conftest.py b/tests/components/midea/conftest.py index 8baae012f62a5..4aa408e1a33b2 100644 --- a/tests/components/midea/conftest.py +++ b/tests/components/midea/conftest.py @@ -46,11 +46,12 @@ def __init__( 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 @@ def set_attribute(self, attr: str, value: Any) -> None: 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.""" diff --git a/tests/components/midea/snapshots/test_climate.ambr b/tests/components/midea/snapshots/test_climate.ambr index 9edfeb466dcb9..ad089cab55bcb 100644 --- a/tests/components/midea/snapshots/test_climate.ambr +++ b/tests/components/midea/snapshots/test_climate.ambr @@ -275,10 +275,10 @@ 'area_id': None, 'capabilities': dict({ : list([ - 'Low', - 'Medium', - 'High', - 'Auto', + 'low', + 'medium', + 'high', + 'auto', ]), : list([ , @@ -335,12 +335,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ : None, - : 'High', + : 'high', : list([ - 'Low', - 'Medium', - 'High', - 'Auto', + 'low', + 'medium', + 'high', + 'auto', ]), : 'MDV Wi-Fi Controller', : list([ diff --git a/tests/components/midea/test_climate.py b/tests/components/midea/test_climate.py index 749a6158b6004..12b97b552ca63 100644 --- a/tests/components/midea/test_climate.py +++ b/tests/components/midea/test_climate.py @@ -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, }, diff --git a/tests/components/midea/test_config_flow.py b/tests/components/midea/test_config_flow.py index a999888512654..b4ac5cb92e981 100644 --- a/tests/components/midea/test_config_flow.py +++ b/tests/components/midea/test_config_flow.py @@ -6,6 +6,7 @@ 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 ( @@ -169,6 +170,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", @@ -180,6 +182,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None: None, True, {}, + None, {}, None, "invalid_token", @@ -191,6 +194,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None: None, True, {}, + None, {}, None, "invalid_device_ip", @@ -202,6 +206,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None: None, True, {}, + None, {}, None, "invalid_device_id_for_ip", @@ -219,6 +224,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None: None, True, {}, + None, {}, None, "ip_address_mismatch", @@ -236,6 +242,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None: None, True, {}, + None, {}, None, "protocol_mismatch", @@ -252,6 +259,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None: None, True, {}, + None, {}, None, "type_mismatch", @@ -263,6 +271,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None: False, True, {}, + None, {}, None, "device_auth_failed", @@ -274,6 +283,7 @@ async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None: None, False, {}, + None, {}, None, "preset_login_failed", @@ -285,11 +295,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( @@ -299,6 +322,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, @@ -323,7 +347,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( @@ -702,6 +728,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}} @@ -1253,6 +1423,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: