Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 23 additions & 54 deletions homeassistant/components/midea/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Comment thread
chemelli74 marked this conversation as resolved.

Expand Down Expand Up @@ -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]
Comment thread
chemelli74 marked this conversation as resolved.

@property
@override
Expand Down Expand Up @@ -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)
Comment thread
chemelli74 marked this conversation as resolved.


class MideaCCClimate(MideaClimate):
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Comment thread
chemelli74 marked this conversation as resolved.
)

@property
Expand Down Expand Up @@ -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)
Comment thread
chemelli74 marked this conversation as resolved.


class MideaFBClimate(MideaClimate):
Expand Down
97 changes: 79 additions & 18 deletions homeassistant/components/midea/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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"):
Expand All @@ -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)
Comment thread
chemelli74 marked this conversation as resolved.
# try to get a passed key, without default_key
keys = await self._check_key_from_cloud(
device_id,
Expand All @@ -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"]
Expand All @@ -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]:
Expand Down Expand Up @@ -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]))
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/midea/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Comment thread
chemelli74 marked this conversation as resolved.
}
7 changes: 7 additions & 0 deletions homeassistant/components/midea/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading