diff --git a/CODEOWNERS b/CODEOWNERS index 5c6830e928823d..2ef3cb93b5ed28 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1134,6 +1134,8 @@ CLAUDE.md @home-assistant/core /tests/components/metoffice/ @MrHarcombe @avee87 /homeassistant/components/microbees/ @microBeesTech /tests/components/microbees/ @microBeesTech +/homeassistant/components/midea_lan/ @chemelli74 @rokam @wuwentao +/tests/components/midea_lan/ @chemelli74 @rokam @wuwentao /homeassistant/components/miele/ @astrandb /tests/components/miele/ @astrandb /homeassistant/components/mikrotik/ @engrbm87 @chemelli74 diff --git a/homeassistant/components/midea_lan/__init__.py b/homeassistant/components/midea_lan/__init__.py new file mode 100644 index 00000000000000..ed963ecbf9be54 --- /dev/null +++ b/homeassistant/components/midea_lan/__init__.py @@ -0,0 +1,76 @@ +"""The Midea LAN integration.""" + +from midealocal.const import ProtocolVersion +from midealocal.devices import device_selector + +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_IP_ADDRESS, + CONF_MODEL, + CONF_NAME, + CONF_PORT, + CONF_PROTOCOL, + CONF_TOKEN, + CONF_TYPE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import CONF_KEY, CONF_SUBTYPE +from .entity import MideaLanConfigEntry + +_PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: MideaLanConfigEntry) -> bool: + """Set up Midea LAN from a config entry.""" + + data = entry.data + device_id: int = data[CONF_DEVICE_ID] + + device = await hass.async_add_executor_job( + device_selector, + data[CONF_NAME], + device_id, + data[CONF_TYPE], + data[CONF_IP_ADDRESS], + data[CONF_PORT], + data[CONF_TOKEN], + data[CONF_KEY], + ProtocolVersion(data[CONF_PROTOCOL]), + data[CONF_MODEL], + data[CONF_SUBTYPE], + "", + ) + if device is None: + raise ConfigEntryError("Unable to initialize device") + + connected = await hass.async_add_executor_job(device.connect, True) + if not connected: + # connect() swallows AuthException/SocketException internally and can + # leave the socket open even though it reports failure, so it must be + # closed explicitly here to avoid a ResourceWarning. + await hass.async_add_executor_job(device.close_socket) + raise ConfigEntryNotReady(f"Unable to connect to device {device_id}") + + # The library's reconnect loop keeps retrying with a growing backoff + # (up to 600s) without checking for a stop request while sleeping, so + # device.close() alone cannot guarantee the background thread exits + # promptly when offline. Marking it a daemon thread ensures it can + # never block Home Assistant shutdown as a zombie thread. + device.daemon = True + await hass.async_add_executor_job(device.open) + entry.runtime_data = device + + async def _close_device() -> None: + await hass.async_add_executor_job(device.close) + + entry.async_on_unload(_close_device) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MideaLanConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/midea_lan/climate.py b/homeassistant/components/midea_lan/climate.py new file mode 100644 index 00000000000000..5fb28a6b7ebdd7 --- /dev/null +++ b/homeassistant/components/midea_lan/climate.py @@ -0,0 +1,725 @@ +"""Midea Climate entries.""" + +from dataclasses import dataclass +import logging +from typing import Any, cast, override + +from midealocal.const import DeviceType +from midealocal.devices.ac import DeviceAttributes as ACAttributes, MideaACDevice +from midealocal.devices.c3 import MideaC3Device +from midealocal.devices.c3.const import DeviceAttributes as C3Attributes +from midealocal.devices.cc import DeviceAttributes as CCAttributes, MideaCCDevice +from midealocal.devices.cf import DeviceAttributes as CFAttributes, MideaCFDevice +from midealocal.devices.fb import DeviceAttributes as FBAttributes, MideaFBDevice + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + PRESET_SLEEP, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_ON, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityDescription, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_WHOLE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, FanSpeed +from .entity import MideaEntity, MideaLanConfigEntry + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +TEMPERATURE_MAX = 30 +TEMPERATURE_MIN = 16 + +TEMPERATURE_MAX_C3 = 60 +TEMPERATURE_MIN_C3 = 5 + +FAN_SILENT = "silent" +FAN_FULL_SPEED = "full" + +FEATURES_TARGET_AND_POWER = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON +) + +type MideaClimateDevice = ( + MideaACDevice | MideaCCDevice | MideaCFDevice | MideaC3Device | MideaFBDevice +) + + +@dataclass(kw_only=True, frozen=True) +class MideaClimateEntityDescription(ClimateEntityDescription): + """Description for a Midea climate entity.""" + + models: list[DeviceType] + zone: int | None = None + + +CLIMATE_ENTITIES: list[MideaClimateEntityDescription] = [ + MideaClimateEntityDescription( + key="climate", + models=[DeviceType.AC, DeviceType.CC, DeviceType.CF, DeviceType.FB], + ), + MideaClimateEntityDescription( + key="climate_zone1", + models=[DeviceType.C3], + translation_key="climate_zone1", + zone=0, + ), + MideaClimateEntityDescription( + key="climate_zone2", + models=[DeviceType.C3], + translation_key="climate_zone2", + zone=1, + entity_registry_enabled_default=False, + ), +] + +_PRESET_TO_ATTR: dict[str, str] = { + PRESET_AWAY: "frost_protect", + PRESET_COMFORT: "comfort_mode", + PRESET_SLEEP: "sleep_mode", + PRESET_ECO: "eco_mode", + PRESET_BOOST: "boost_mode", +} + +_ATTR_TO_PRESET: dict[str, str] = {v: k for k, v in _PRESET_TO_ATTR.items()} + +_SWING_MODE_MAP: dict[str, tuple[bool, bool]] = { + SWING_OFF: (False, False), + SWING_VERTICAL: (True, False), + SWING_HORIZONTAL: (False, True), + SWING_BOTH: (True, True), +} + +_SWING_STATE_MAP: dict[tuple[bool, bool], str] = { + v: k for k, v in _SWING_MODE_MAP.items() +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MideaLanConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up climate entries.""" + device = config_entry.runtime_data + + entities: list[MideaClimate] = [] + for description in CLIMATE_ENTITIES: + if device.device_type not in description.models: + continue + if device.device_type == DeviceType.AC: + entities.append(MideaACClimate(cast(MideaACDevice, device), description)) + elif device.device_type == DeviceType.CC: + entities.append(MideaCCClimate(cast(MideaCCDevice, device), description)) + elif device.device_type == DeviceType.CF: + entities.append(MideaCFClimate(cast(MideaCFDevice, device), description)) + elif device.device_type == DeviceType.C3 and description.zone is not None: + entities.append( + MideaC3Climate( + cast(MideaC3Device, device), description, description.zone + ) + ) + elif device.device_type == DeviceType.FB: + entities.append(MideaFBClimate(cast(MideaFBDevice, device), description)) + async_add_entities(entities) + + +class MideaClimate(MideaEntity, ClimateEntity): + """Midea Climate Entries Base Class.""" + + _device: MideaClimateDevice + + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_max_temp = TEMPERATURE_MAX + _attr_min_temp = TEMPERATURE_MIN + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _zone: int | None = None + + def __init__( + self, + device: MideaClimateDevice, + description: MideaClimateEntityDescription, + ) -> None: + """Midea Climate entity init.""" + super().__init__(device, description.key) + self.entity_description = description + + def _float_attribute(self, attr: str) -> float | None: + """Return a device attribute as float, if convertible.""" + value = self._device.get_attribute(attr) + if not isinstance(value, (int, float, str)): + return None + return float(value) + + @property + @override + def hvac_mode(self) -> HVACMode | None: + """Midea Climate hvac mode.""" + power = self._device.get_attribute(attr="power") + if not isinstance(power, bool): + return None + if not power: + return HVACMode.OFF + + mode = self._device.get_attribute("mode") + if isinstance(mode, int): + return self._protocol_mode_to_hvac(mode) + return None + + def _protocol_mode_to_hvac(self, mode: int) -> HVACMode | None: + """Convert protocol mode value to Home Assistant HVAC mode.""" + if 1 <= mode < len(self.hvac_modes): + return self.hvac_modes[mode] + return None + + def _hvac_to_protocol_mode(self, hvac_mode: HVACMode) -> int: + """Convert Home Assistant HVAC mode to protocol mode value.""" + return self.hvac_modes.index(hvac_mode) + + @property + @override + def target_temperature(self) -> float | None: + """Midea Climate target temperature.""" + return self._float_attribute("target_temperature") + + @property + @override + def current_temperature(self) -> float | None: + """Midea Climate current temperature.""" + return self._float_attribute("indoor_temperature") + + @property + @override + def preset_mode(self) -> str | None: + """Midea Climate preset mode.""" + for attr, preset in _ATTR_TO_PRESET.items(): + if self._device.get_attribute(attr): + return preset + + return PRESET_NONE + + @override + def turn_on(self, **kwargs: Any) -> None: + """Midea Climate turn on.""" + self._device.set_attribute(attr="power", value=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Midea Climate turn off.""" + self._device.set_attribute(attr="power", value=False) + + @override + def set_temperature(self, **kwargs: Any) -> None: + """Midea Climate set temperature.""" + if ATTR_TEMPERATURE not in kwargs: + return + temperature = kwargs[ATTR_TEMPERATURE] + hvac_mode = kwargs.get(ATTR_HVAC_MODE) + 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( + target_temperature=temperature, + mode=mode, + zone=self._zone, + ) + + @override + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Midea Climate set hvac mode.""" + if hvac_mode == HVACMode.OFF: + self.turn_off() + else: + self._device.set_attribute( + attr="mode", + value=self._hvac_to_protocol_mode(hvac_mode), + ) + + @override + def set_preset_mode(self, preset_mode: str) -> None: + """Midea Climate set preset mode.""" + if new_attr := _PRESET_TO_ATTR.get(preset_mode): + self._device.set_attribute(attr=new_attr, value=True) + return + old_mode = self.preset_mode + old_attr = _PRESET_TO_ATTR.get(old_mode) if isinstance(old_mode, str) else None + if old_attr: + self._device.set_attribute(attr=old_attr, value=False) + + +class MideaACClimate(MideaClimate): + """Midea AC Climate Entries.""" + + _device: MideaACDevice + + _fan_thresholds: tuple[tuple[int, str], ...] = ( + (FanSpeed.AUTO, FAN_AUTO), + (FanSpeed.FULL_SPEED, FAN_FULL_SPEED), + (FanSpeed.HIGH, FAN_HIGH), + (FanSpeed.MEDIUM, FAN_MEDIUM), + (FanSpeed.LOW, FAN_LOW), + ) + + _fan_speeds: dict[str, int] = { + FAN_SILENT: 20, + FAN_LOW: 40, + FAN_MEDIUM: 60, + FAN_HIGH: 80, + FAN_FULL_SPEED: 100, + FAN_AUTO: 102, + } + _attr_fan_modes: list[str] = [ + FAN_SILENT, + FAN_LOW, + FAN_MEDIUM, + FAN_HIGH, + FAN_FULL_SPEED, + FAN_AUTO, + ] + + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + ] + _attr_swing_modes: list[str] = [ + SWING_OFF, + SWING_VERTICAL, + SWING_HORIZONTAL, + SWING_BOTH, + ] + _attr_preset_modes = [ + PRESET_NONE, + PRESET_COMFORT, + PRESET_ECO, + PRESET_BOOST, + PRESET_SLEEP, + PRESET_AWAY, + ] + + def __init__( + self, + device: MideaACDevice, + description: MideaClimateEntityDescription, + ) -> None: + """Midea AC Climate entity init.""" + super().__init__(device, description) + self._attr_target_temperature_step = float( + PRECISION_WHOLE if self._device.temperature_step == 1 else PRECISION_HALVES, + ) + + @property + @override + def min_temp(self) -> float: + """Midea AC Climate min temperature.""" + min_temperature = self._float_attribute(ACAttributes.min_temperature) + if min_temperature is None: + return float(TEMPERATURE_MIN) + return min_temperature + + @property + @override + def max_temp(self) -> float: + """Midea AC Climate max temperature.""" + max_temperature = self._float_attribute(ACAttributes.max_temperature) + if max_temperature is None: + return float(TEMPERATURE_MAX) + return max_temperature + + @property + @override + def fan_mode(self) -> str | None: + """Midea AC Climate fan mode.""" + fan_speed = self._device.get_attribute(ACAttributes.fan_speed) + if not isinstance(fan_speed, int): + return None + for threshold, mode in self._fan_thresholds: + if fan_speed > threshold: + return mode + return FAN_SILENT + + @property + @override + def swing_mode(self) -> str | None: + """Midea AC Climate swing mode.""" + vertical = bool(self._device.get_attribute(ACAttributes.swing_vertical)) + horizontal = bool(self._device.get_attribute(ACAttributes.swing_horizontal)) + return _SWING_STATE_MAP.get((vertical, horizontal)) + + @property + @override + def current_humidity(self) -> float | None: + """Return the current indoor humidity, or None if unavailable.""" + raw = self._device.get_attribute(ACAttributes.indoor_humidity) + # Some devices report invalid values (0 or 0xFF) for this sensor + # so filter those out and return None instead. + if isinstance(raw, (int, float)) and raw not in {0, 0xFF}: + return float(raw) + return None + + @override + def set_fan_mode(self, fan_mode: str) -> None: + """Midea AC Climate set fan mode.""" + fan_speed = self._fan_speeds[fan_mode] + self._device.set_attribute(attr=ACAttributes.fan_speed, value=fan_speed) + + @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, + ) + + +class MideaCCClimate(MideaClimate): + """Midea CC Climate Entries.""" + + _device: MideaCCDevice + + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.AUTO, + ] + _attr_swing_modes = [SWING_OFF, SWING_ON] + _attr_preset_modes = [PRESET_NONE, PRESET_SLEEP, PRESET_ECO] + + @property + @override + def fan_modes(self) -> list[str] | None: + """Midea CC Climate fan modes.""" + return self._device.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 + + @property + @override + def target_temperature_step(self) -> float | None: + """Midea CC Climate target temperature step.""" + return self._float_attribute(CCAttributes.temperature_precision) + + @property + @override + def swing_mode(self) -> str | None: + """Midea CC Climate swing mode.""" + swing = self._device.get_attribute(CCAttributes.swing) + if not isinstance(swing, bool): + return None + return SWING_ON if swing else SWING_OFF + + @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) + + @override + def set_swing_mode(self, swing_mode: str) -> None: + """Midea CC Climate set swing mode.""" + self._device.set_attribute( + attr=CCAttributes.swing, + value=swing_mode == SWING_ON, + ) + + +class MideaCFClimate(MideaClimate): + """Midea CF Climate Entries.""" + + _device: MideaCFDevice + + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.HEAT, + ] + _attr_target_temperature_step: float | None = PRECISION_WHOLE + + _attr_supported_features = FEATURES_TARGET_AND_POWER + + @override + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Midea CF Climate set hvac mode.""" + 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), + ) + + @property + @override + def min_temp(self) -> float: + """Midea CF Climate min temperature.""" + min_temperature = self._float_attribute(CFAttributes.min_temperature) + if min_temperature is None: + return float(TEMPERATURE_MIN) + return min_temperature + + @property + @override + def max_temp(self) -> float: + """Midea CF Climate max temperature.""" + max_temperature = self._float_attribute(CFAttributes.max_temperature) + if max_temperature is None: + return float(TEMPERATURE_MAX) + return max_temperature + + @property + @override + def current_temperature(self) -> float | None: + """Midea CF Climate current temperature.""" + return self._float_attribute(CFAttributes.current_temperature) + + +class MideaC3Climate(MideaClimate): + """Midea C3 Climate Entries.""" + + _device: MideaC3Device + _zone: int + + _powers: tuple[C3Attributes, ...] = ( + C3Attributes.zone1_power, + C3Attributes.zone2_power, + ) + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.HEAT, + ] + + def __init__( + self, + device: MideaC3Device, + description: MideaClimateEntityDescription, + zone: int, + ) -> None: + """Midea C3 Climate entity init.""" + super().__init__(device, description) + self._zone = zone + self._power_attr = MideaC3Climate._powers[zone] + + def _temperature(self, *, minimum: bool) -> list[float]: + """Midea C3 Climate temperature.""" + value = ( + C3Attributes.temperature_min if minimum else C3Attributes.temperature_max + ) + temperatures = self._device.get_attribute(value) + fallback = float(TEMPERATURE_MIN_C3 if minimum else TEMPERATURE_MAX_C3) + if not isinstance(temperatures, list): + return [fallback, fallback] + parsed_temperatures = [float(temperature) for temperature in temperatures] + if len(parsed_temperatures) < 2: + return [fallback, fallback] + return [ + fallback if temperature == 0.0 else temperature + for temperature in parsed_temperatures + ] + + _attr_supported_features = FEATURES_TARGET_AND_POWER + + @property + @override + def target_temperature_step(self) -> float: + """Midea C3 Climate target temperature step.""" + zone_temp_type = self._device.get_attribute(C3Attributes.zone_temp_type) + if not isinstance(zone_temp_type, list) or len(zone_temp_type) <= self._zone: + return float(PRECISION_HALVES) + return float( + PRECISION_WHOLE if zone_temp_type[self._zone] else PRECISION_HALVES, + ) + + @property + @override + def min_temp(self) -> float: + """Midea C3 Climate min temperature.""" + return self._temperature(minimum=True)[self._zone] + + @property + @override + def max_temp(self) -> float: + """Midea C3 Climate max temperature.""" + return self._temperature(minimum=False)[self._zone] + + @override + def turn_on(self, **kwargs: Any) -> None: + """Midea C3 Climate turn on.""" + self._device.set_attribute(attr=self._power_attr, value=True) + + @override + def turn_off(self, **kwargs: Any) -> None: + """Midea C3 Climate turn off.""" + self._device.set_attribute(attr=self._power_attr, value=False) + + @property + @override + def hvac_mode(self) -> HVACMode | None: + """Midea C3 Climate hvac mode.""" + power = self._device.get_attribute(self._power_attr) + if not isinstance(power, bool): + return None + if not power: + return HVACMode.OFF + mode = self._device.get_attribute(C3Attributes.mode) + if isinstance(mode, int): + return self._protocol_mode_to_hvac(mode) + return None + + @property + @override + def target_temperature(self) -> float | None: + """Midea C3 Climate target temperature.""" + target_temperature = self._device.get_attribute(C3Attributes.target_temperature) + if ( + not isinstance(target_temperature, list) + or len(target_temperature) <= self._zone + ): + return None + return float(target_temperature[self._zone]) + + @property + @override + def current_temperature(self) -> float | None: + """Midea C3 Climate current temperature.""" + return self._float_attribute(C3Attributes.temp_tw_out) + + @override + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Midea C3 Climate set hvac mode.""" + if hvac_mode == HVACMode.OFF: + self.turn_off() + else: + self._device.set_mode(self._zone, self._hvac_to_protocol_mode(hvac_mode)) + + +class MideaFBClimate(MideaClimate): + """Midea FB Climate Entries.""" + + _device: MideaFBDevice + + _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + _attr_max_temp = 35 + _attr_min_temp = 5 + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _attr_target_temperature_step = PRECISION_WHOLE + + def __init__( + self, + device: MideaFBDevice, + description: MideaClimateEntityDescription, + ) -> None: + """Midea FB Climate entity init.""" + super().__init__(device, description) + self._attr_preset_modes: list[str] = self._device.modes + + @property + @override + def preset_mode(self) -> str | None: + """Midea FB Climate preset mode.""" + preset_mode = self._device.get_attribute(attr=FBAttributes.mode) + if not isinstance(preset_mode, str): + return None + return preset_mode + + @property + @override + def hvac_mode(self) -> HVACMode | None: + """Midea FB Climate hvac mode.""" + hvac_mode = self._device.get_attribute(attr=FBAttributes.power) + if not isinstance(hvac_mode, bool): + return None + return HVACMode.HEAT if hvac_mode else HVACMode.OFF + + @property + @override + def current_temperature(self) -> float | None: + """Midea FB Climate current temperature.""" + return self._float_attribute(FBAttributes.current_temperature) + + @override + def set_temperature(self, **kwargs: Any) -> None: + """Midea FB Climate set temperature.""" + wants_heat = kwargs.get(ATTR_HVAC_MODE) == HVACMode.HEAT + if wants_heat and self.hvac_mode == HVACMode.OFF: + self.turn_on() + super().set_temperature(**kwargs) + + @override + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Midea FB Climate set hvac mode.""" + if hvac_mode == HVACMode.OFF: + self.turn_off() + else: + self.turn_on() + + @override + def set_preset_mode(self, preset_mode: str) -> None: + """Midea FB Climate set preset mode.""" + self._device.set_attribute(attr=FBAttributes.mode, value=preset_mode) diff --git a/homeassistant/components/midea_lan/config_flow.py b/homeassistant/components/midea_lan/config_flow.py new file mode 100644 index 00000000000000..4d571cc1c7f223 --- /dev/null +++ b/homeassistant/components/midea_lan/config_flow.py @@ -0,0 +1,708 @@ +"""Config flow for Midea LAN.""" + +from operator import itemgetter +from typing import Any, override + +from midealocal.cloud import ( + MideaCloud, + get_default_cloud, + get_midea_cloud, + get_preset_account_cloud, +) +from midealocal.const import DeviceType, ProtocolVersion +from midealocal.device import MideaDevice +from midealocal.discover import discover +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_DEVICE, + CONF_DEVICE_ID, + CONF_IP_ADDRESS, + CONF_MODEL, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_TOKEN, + CONF_TYPE, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +from .const import _LOGGER, CONF_ACCOUNT, CONF_KEY, CONF_SERVER, CONF_SUBTYPE, DOMAIN +from .device_catalog import MIDEA_DEVICE_NAMES + +DEFAULT_CLOUD: str = get_default_cloud() + +LOGIN_MODE_PRESET = "preset" +LOGIN_MODE_ACCOUNT = "account" + + +def _connect_and_close(dm: MideaDevice) -> bool: + """Connect to the device, always closing the socket afterwards.""" + try: + return dm.connect(check_protocol=True) + finally: + dm.close_socket() + + +class MideaLanConfigFlow(ConfigFlow, domain=DOMAIN): + """Define current integration setup steps. + + Use ConfigFlow handle to support config entries + ConfigFlow will manage the creation of entries from user input, discovery + """ + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self) -> None: + """MideaLanConfigFlow class.""" + self.available_device: dict = {} + self.devices: dict = {} + self.found_device: dict[str, Any] = {} + self.supports: dict = {} + self.cloud: MideaCloud | None = None + self._login_data: dict[str, str] | None = None + unsorted = dict(MIDEA_DEVICE_NAMES) + + # sort and assign supports + self.supports = dict(sorted(unsorted.items(), key=itemgetter(1))) + + # Try the preset account first, as it is usually enough to retrieve most data. + # Users registered on a different server may not be able to retrieve the + # required key with their own credentials. + # If this fails, fall back to user-provided credentials. + preset_account = get_preset_account_cloud() + self.preset_account: str = preset_account["username"] + self.preset_password: str = preset_account["password"] + self.preset_cloud_name: str = preset_account["cloud_name"] + + def _clear_login_state(self) -> None: + """Clear flow-scoped credentials and cloud.""" + self._login_data = None + self.cloud = None + + 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(): + if str(device_id) == str( + entry.data.get(CONF_DEVICE_ID) + ) or ip_address == entry.data.get(CONF_IP_ADDRESS): + return True + return False + + @override + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Start a user flow.""" + return self.async_show_menu( + step_id="user", + menu_options=["search", "manually", "list"], + ) + + async def async_step_login_credentials( + self, + user_input: dict[str, Any] | None = None, + error: str | None = None, + ) -> ConfigFlowResult: + """User login steps.""" + # get cloud servers configs + cloud_servers = await MideaCloud.get_cloud_servers() + cloud_server_options = list(cloud_servers.values()) + if not cloud_server_options: + cloud_server_options = [DEFAULT_CLOUD] + default_server = next( + (server for server in cloud_server_options if server == DEFAULT_CLOUD), + cloud_server_options[0], + ) + # user input data exist + if user_input is not None: + cloud_server = user_input[CONF_SERVER] + account = user_input[CONF_ACCOUNT] + password = user_input[CONF_PASSWORD] + + # cloud login MUST pass with user input or preset account + if await self._check_cloud_login( + cloud_name=cloud_server, + account=account, + password=password, + force_login=True, + ): + self._login_data = { + CONF_ACCOUNT: account, + CONF_PASSWORD: password, + CONF_SERVER: cloud_server, + } + # resume device processing with the already selected device + return await self.async_step_auto( + user_input={CONF_DEVICE: self.found_device[CONF_DEVICE_ID]}, + ) + # return error with login failed + _LOGGER.debug( + "Failed to login to %s cloud with user credentials", + cloud_server, + ) + return self._show_login_credentials_form( + cloud_server_options, + default_server, + user_input=user_input, + error="login_failed", + ) + # user not login, show login form in UI + return self._show_login_credentials_form( + cloud_server_options, + default_server, + user_input=None, + error=error, + ) + + def _show_login_credentials_form( + self, + cloud_server_options: list[str], + default_server: str, + user_input: dict[str, Any] | None, + error: str | None = None, + ) -> ConfigFlowResult: + """Show the login form, retaining any previously entered values.""" + schema = vol.Schema( + { + vol.Required(CONF_ACCOUNT): str, + vol.Required(CONF_PASSWORD): str, + vol.Required( + CONF_SERVER, + default=default_server, + ): SelectSelector( + SelectSelectorConfig( + options=cloud_server_options, + ) + ), + }, + ) + if user_input is not None: + schema = self.add_suggested_values_to_schema(schema, user_input) + return self.async_show_form( + step_id="login_credentials", + data_schema=schema, + errors={"base": error} if error else None, + ) + + async def async_step_auth_method( + self, + user_input: dict[str, Any] | None = None, + error: str | None = None, + ) -> ConfigFlowResult: + """Select how to authenticate.""" + + if user_input is not None: + if user_input["login_mode"] == LOGIN_MODE_ACCOUNT: + return await self.async_step_login_credentials() + + # preset selected + if await self._check_cloud_login(force_login=True): + self._login_data = { + CONF_SERVER: DEFAULT_CLOUD, + CONF_ACCOUNT: self.preset_account, + CONF_PASSWORD: self.preset_password, + } + # resume device processing with the already selected device + return await self.async_step_auto( + user_input={CONF_DEVICE: self.found_device[CONF_DEVICE_ID]}, + ) + + return await self.async_step_auth_method( + error="preset_login_failed", + ) + + return self.async_show_form( + step_id="auth_method", + data_schema=vol.Schema( + { + vol.Required( + "login_mode", + default=LOGIN_MODE_PRESET, + ): SelectSelector( + SelectSelectorConfig( + options=[ + LOGIN_MODE_PRESET, + LOGIN_MODE_ACCOUNT, + ], + translation_key="login_mode", + ) + ), + } + ), + errors={"base": error} if error else None, + ) + + async def async_step_list( + self, + user_input: dict[str, Any] | None = None, + error: str | None = None, + ) -> ConfigFlowResult: + """List all devices and show device info in web UI.""" + if user_input is not None: + return await self.async_step_user() + + # get all devices list + all_devices = await self.hass.async_add_executor_job(discover) + # available devices exist + if len(all_devices) > 0: + table = ( + "Appliance code|Type|IP address|SN|Supported\n:--:|:--:|:--:|:--:|:--:" + ) + for device_id, device in all_devices.items(): + supported = device.get(CONF_TYPE) in self.supports + table += ( + f"\n{device_id}|{f'{device.get(CONF_TYPE):02X}'}|" + f"{device.get(CONF_IP_ADDRESS)}|" + f"{device.get('sn')}|" + f"{'YES' if supported else 'NO'}" + ) + # no available device + else: + table = "Not found" + # show devices list result in UI + return self.async_show_form( + step_id="list", + description_placeholders={"table": table}, + errors={"base": error} if error else None, + ) + + async def async_step_search( + self, + user_input: dict[str, Any] | None = None, + error: str | None = None, + ) -> ConfigFlowResult: + """Search device with auto mode or ip address.""" + # input is not None, using ip_address to discovery device + if user_input is not None: + # auto mode, ip_address is None + if user_input[CONF_IP_ADDRESS].lower() == "auto": + ip_address = None + # ip exist + else: + ip_address = user_input[CONF_IP_ADDRESS] + # use midea-local discover() to get devices list with ip_address + self.devices = await self.hass.async_add_executor_job( + lambda: discover(list(self.supports.keys()), ip_address=ip_address), + ) + self.available_device = {} + for device_id, device in self.devices.items(): + # remove exist devices and only return new devices + if not self._already_configured( + str(device_id), + device[CONF_IP_ADDRESS], + ): + # fmt: off + self.available_device[device_id] = ( + f"{device_id} ({self.supports.get(device.get(CONF_TYPE))})" + ) + # fmt: on + if len(self.available_device) > 0: + return await self.async_step_auto() + return await self.async_step_search(error="no_devices") + # show discovery device input form with auto or ip address in web UI + return self.async_show_form( + step_id="search", + data_schema=vol.Schema( + {vol.Required(CONF_IP_ADDRESS, default="auto"): str}, + ), + errors={"base": error} if error else None, + ) + + async def _check_cloud_login( + self, + cloud_name: str | None = None, + account: str | None = None, + password: str | None = None, + force_login: bool = False, + ) -> bool: + """Check cloud login.""" + # default to preset account + if cloud_name is None or account is None or password is None: + cloud_name = self.preset_cloud_name + account = self.preset_account + password = self.preset_password + + session = async_get_clientsession(self.hass) + + # init cloud object or force reinit with new one + if self.cloud is None or force_login: + self.cloud = get_midea_cloud( + cloud_name, + session, + account, + password, + ) + # check cloud login after self.cloud exist + if await self.cloud.login(): + _LOGGER.debug( + "Cloud login succeeded for %s", + cloud_name, + ) + return True + _LOGGER.debug( + "Unable to login to %s cloud", + cloud_name, + ) + return False + + async def _check_key_from_cloud( + self, + appliance_id: int, + default_key: bool = True, + ) -> dict[str, Any]: + """Use preset DEFAULT_CLOUD account to get v3 device token and key.""" + device = self.devices[appliance_id] + + # _check_cloud_login always succeeds before this is called, setting self.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) + if default_key: + keys = {**keys, **(await MideaCloud.get_default_keys())} + # 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(_connect_and_close, dm) + if connected: + return value + # return debug log with failed key + _LOGGER.debug( + "Connect device using method %s token/key failed", + k, + ) + _LOGGER.debug( + "Unable to connect device with all the token/key", + ) + return {"error": "connect_error"} + + async def async_step_auto( + self, + user_input: dict[str, Any] | None = None, + error: str | None = None, + ) -> ConfigFlowResult: + """Discovery device detail info.""" + # input device exist + if user_input is not None: + device_id = user_input[CONF_DEVICE] + device = self.devices[device_id] + self.found_device = { + CONF_DEVICE_ID: device_id, + CONF_NAME: self.supports.get(device.get(CONF_TYPE), str(device_id)), + CONF_TYPE: device.get(CONF_TYPE), + CONF_PROTOCOL: device.get(CONF_PROTOCOL), + CONF_IP_ADDRESS: device.get(CONF_IP_ADDRESS), + CONF_PORT: device.get(CONF_PORT), + CONF_MODEL: device.get(CONF_MODEL), + } + + # MUST get a auth passed token/key for v3 device, disable add before pass + if device.get(CONF_PROTOCOL) == ProtocolVersion.V3: + # check login cache, show login web if no cache + if self._login_data is None or self.cloud is None: + return await self.async_step_auth_method() + + # get subtype from cloud + if device_info := await self.cloud.get_device_info(device_id): + # set subtype with model_number + if cloud_name := device_info.get("name"): + self.found_device[CONF_NAME] = cloud_name + self.found_device[CONF_SUBTYPE] = device_info.get("model_number") + + # phase 1, try with user input login data + keys = await self._check_key_from_cloud(device_id) + + # no available key, continue the phase 2 + if not keys.get("token") or not keys.get("key"): + _LOGGER.debug( + "Can't get valid token using user credentials on %s", + self._login_data[CONF_SERVER], + ) + + # get key phase 2: reinit cloud with preset account + if not await self._check_cloud_login(force_login=True): + self._clear_login_state() + return await self.async_step_auto( + error="preset_login_failed", + ) + # try to get a passed key, without default_key + keys = await self._check_key_from_cloud( + device_id, + default_key=False, + ) + + # phase 2 got no available token/key, disable device add + if not keys.get("token") or not keys.get("key"): + _LOGGER.debug( + "Can't get available token from Midea server for device %s", + device_id, + ) + self._clear_login_state() + return await self.async_step_auto( + error="token_unavailable", + ) + # get key pass + self.found_device[CONF_TOKEN] = keys["token"] + self.found_device[CONF_KEY] = keys["key"] + self._clear_login_state() + return await self._async_create_midea_entry( + self._found_device_to_user_input(), + ) + # v1/v2 device add without token/key, no cloud interaction needed + self._clear_login_state() + return await self._async_create_midea_entry( + self._found_device_to_user_input(), + ) + # show available device list in UI + return self.async_show_form( + step_id="auto", + data_schema=vol.Schema( + { + vol.Required( + CONF_DEVICE, + default=next(iter(self.available_device.keys())), + ): vol.In(self.available_device), + }, + ), + errors={"base": error} if error else None, + ) + + def _found_device_to_user_input(self) -> dict[str, Any]: + """Build a manual-step-shaped user_input from the found device.""" + return { + CONF_DEVICE_ID: self.found_device[CONF_DEVICE_ID], + CONF_TYPE: self.found_device[CONF_TYPE], + CONF_IP_ADDRESS: self.found_device[CONF_IP_ADDRESS], + CONF_PORT: self.found_device[CONF_PORT], + CONF_PROTOCOL: self.found_device[CONF_PROTOCOL], + CONF_MODEL: self.found_device[CONF_MODEL], + CONF_SUBTYPE: self.found_device.get(CONF_SUBTYPE) or 0, + CONF_TOKEN: self.found_device.get(CONF_TOKEN) or "", + CONF_KEY: self.found_device.get(CONF_KEY) or "", + } + + async def _async_create_midea_entry( + self, + user_input: dict[str, Any], + ) -> ConfigFlowResult: + """Validate device connection with all the input and create the entry.""" + device_id = user_input[CONF_DEVICE_ID] + + # check unique_id before attempting a connection, so a re-add of an + # already configured but currently offline device aborts (already_configured) + # instead of failing with device_auth_failed + 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(_connect_and_close, dm) + if connected: + device_type = user_input[CONF_TYPE] + found_name = self.found_device.get(CONF_NAME) + if isinstance(found_name, str) and found_name: + name = found_name + else: + name = self.supports.get(device_type, str(device_id)) + data = { + CONF_NAME: name, + CONF_DEVICE_ID: device_id, + CONF_TYPE: device_type, + CONF_PROTOCOL: user_input[CONF_PROTOCOL], + CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS], + CONF_PORT: user_input[CONF_PORT], + CONF_MODEL: user_input[CONF_MODEL], + CONF_SUBTYPE: user_input[CONF_SUBTYPE], + CONF_TOKEN: user_input[CONF_TOKEN], + CONF_KEY: user_input[CONF_KEY], + } + + return self.async_create_entry( + title=name, + data=data, + ) + return self._show_manually_form(user_input, error="device_auth_failed") + + async def async_step_manually( + self, + user_input: dict[str, Any] | None = None, + error: str | None = None, + ) -> ConfigFlowResult: + """Add device with device detail info.""" + if user_input is not None: + try: + bytearray.fromhex(user_input[CONF_TOKEN]) + bytearray.fromhex(user_input[CONF_KEY]) + except ValueError: + return self._show_manually_form(user_input, error="invalid_token") + + device_id = user_input[CONF_DEVICE_ID] + # (re)discover whenever the requested device isn't already known, + # so correcting the IP/device_id and resubmitting can succeed + if device_id not in self.devices: + ip = user_input[CONF_IP_ADDRESS] + # discover device + self.devices = await self.hass.async_add_executor_job( + lambda: discover(list(self.supports.keys()), ip_address=ip), + ) + # discover result MUST exist + if len(self.devices) != 1: + return self._show_manually_form( + user_input, error="invalid_device_ip" + ) + # check all the input, disable error add + device_id = next(iter(self.devices.keys())) + + # check if device_id is correctly set for that IP + if user_input[CONF_DEVICE_ID] != device_id: + return self._show_manually_form( + user_input, + error="invalid_device_id_for_ip", + ) + + device = self.devices[device_id] + if user_input[CONF_IP_ADDRESS] != device.get(CONF_IP_ADDRESS): + return self._show_manually_form( + user_input, + error="ip_address_mismatch", + ) + if user_input[CONF_PROTOCOL] != device.get(CONF_PROTOCOL): + return self._show_manually_form( + user_input, + error="protocol_mismatch", + ) + if user_input[CONF_TYPE] != device.get(CONF_TYPE): + return self._show_manually_form( + user_input, + error="type_mismatch", + ) + + # try to get token/key with preset account + if user_input[CONF_PROTOCOL] == ProtocolVersion.V3 and ( + len(user_input[CONF_TOKEN]) == 0 or len(user_input[CONF_KEY]) == 0 + ): + # init cloud with preset account + result = await self._check_cloud_login() + if not result: + return self._show_manually_form( + user_input, + error="preset_login_failed", + ) + # try to get a passed key + keys = await self._check_key_from_cloud(int(user_input[CONF_DEVICE_ID])) + + # no available token/key, disable device add + if not keys.get("token") or not keys.get("key"): + _LOGGER.debug( + "Can't get a valid token from Midea server for device %s", + user_input[CONF_DEVICE_ID], + ) + return self._show_manually_form( + user_input, + error="token_unavailable", + ) + + # set token/key from preset account + user_input[CONF_KEY] = keys["key"] + user_input[CONF_TOKEN] = keys["token"] + + self.found_device = { + CONF_DEVICE_ID: user_input[CONF_DEVICE_ID], + CONF_NAME: self.found_device.get(CONF_NAME), + CONF_TYPE: user_input[CONF_TYPE], + CONF_PROTOCOL: user_input[CONF_PROTOCOL], + CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS], + CONF_PORT: user_input[CONF_PORT], + CONF_MODEL: user_input[CONF_MODEL], + CONF_TOKEN: user_input[CONF_TOKEN], + CONF_KEY: user_input[CONF_KEY], + } + + return await self._async_create_midea_entry(user_input) + return self._show_manually_form(user_input, error) + + def _show_manually_form( + self, + user_input: dict[str, Any] | None, + error: str | None = None, + ) -> ConfigFlowResult: + """Show the manual step form, retaining any previously entered values.""" + protocol = self.found_device.get(CONF_PROTOCOL) + schema = vol.Schema( + { + vol.Required( + CONF_DEVICE_ID, + default=self.found_device.get(CONF_DEVICE_ID), + ): int, + vol.Required( + CONF_TYPE, + default=(self.found_device.get(CONF_TYPE) or DeviceType.AC), + ): vol.In(self.supports), + vol.Required( + CONF_IP_ADDRESS, + default=self.found_device.get(CONF_IP_ADDRESS), + ): str, + vol.Required( + CONF_PORT, + default=(self.found_device.get(CONF_PORT) or 6444), + ): int, + vol.Required( + CONF_PROTOCOL, + default=(protocol or ProtocolVersion.V3), + ): vol.In( + [protocol] if protocol else ProtocolVersion, + ), + vol.Required( + CONF_MODEL, + default=(self.found_device.get(CONF_MODEL) or "Unknown"), + ): str, + vol.Required( + CONF_SUBTYPE, + default=(self.found_device.get(CONF_SUBTYPE) or 0), + ): int, + vol.Optional( + CONF_TOKEN, + default=(self.found_device.get(CONF_TOKEN) or ""), + ): str, + vol.Optional( + CONF_KEY, + default=(self.found_device.get(CONF_KEY) or ""), + ): str, + }, + ) + if user_input is not None: + schema = self.add_suggested_values_to_schema(schema, user_input) + return self.async_show_form( + step_id="manually", + data_schema=schema, + errors={"base": error} if error else None, + ) diff --git a/homeassistant/components/midea_lan/const.py b/homeassistant/components/midea_lan/const.py new file mode 100644 index 00000000000000..94d6729929ffbd --- /dev/null +++ b/homeassistant/components/midea_lan/const.py @@ -0,0 +1,24 @@ +"""Constants for the Midea LAN integration.""" + +from enum import IntEnum +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "midea_lan" + + +CONF_KEY = "key" +CONF_SUBTYPE = "subtype" +CONF_ACCOUNT = "account" +CONF_SERVER = "server" + + +class FanSpeed(IntEnum): + """FanSpeed reference values.""" + + LOW = 20 + MEDIUM = 40 + HIGH = 60 + FULL_SPEED = 80 + AUTO = 100 diff --git a/homeassistant/components/midea_lan/device_catalog.py b/homeassistant/components/midea_lan/device_catalog.py new file mode 100644 index 00000000000000..ab1446a516d2b2 --- /dev/null +++ b/homeassistant/components/midea_lan/device_catalog.py @@ -0,0 +1,11 @@ +"""Helpers for Midea device names and entity definitions.""" + +from midealocal.const import DeviceType + +MIDEA_DEVICE_NAMES: dict[DeviceType, str] = { + DeviceType.AC: "Air Conditioner", + DeviceType.C3: "Heat Pump Wi-Fi Controller", + DeviceType.CC: "MDV Wi-Fi Controller", + DeviceType.CF: "Heat Pump", + DeviceType.FB: "Electric Heater", +} diff --git a/homeassistant/components/midea_lan/entity.py b/homeassistant/components/midea_lan/entity.py new file mode 100644 index 00000000000000..c1cb53292a7da3 --- /dev/null +++ b/homeassistant/components/midea_lan/entity.py @@ -0,0 +1,79 @@ +"""Base entity for Midea Lan.""" + +import logging +from typing import Any, override + +from midealocal.device import MideaDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .device_catalog import MIDEA_DEVICE_NAMES + +_LOGGER = logging.getLogger(__name__) + +type MideaLanConfigEntry = ConfigEntry[MideaDevice] + + +class MideaEntity(Entity): + """Base Midea entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, device: MideaDevice, entity_key: str) -> None: + """Initialize Midea base entity.""" + self._device = device + self._unique_id = f"{self._device.device_id}_{entity_key}" + self._device_name = self._device.name + + @override + async def async_added_to_hass(self) -> None: + """Register update callback when entity is added.""" + self._device.register_update(self.update_state) + + @override + async def async_will_remove_from_hass(self) -> None: + """Unregister update callback when entity is removed.""" + self._device.unregister_update(self.update_state) + + @property + @override + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo( + manufacturer="Midea", + # Map the device type (numeric ID) to a human-readable model name. + model=MIDEA_DEVICE_NAMES.get(self._device.device_type, "Unknown"), + identifiers={(DOMAIN, str(self._device.device_id))}, + name=self._device_name, + model_id=str(self._device.device_type), + hw_version=str(self._device.subtype), + ) + + @property + @override + def unique_id(self) -> str: + """Return entity unique id.""" + return self._unique_id + + @property + @override + def available(self) -> bool: + """Return entity availability.""" + return bool(self._device.available) + + def update_state(self, status: Any) -> None: + """Update entity state.""" + if self.hass.is_stopping: + _LOGGER.debug( + "MideaEntity update_state for %s [%s] with status %s: HASS is stopping", + self.name, + type(self), + status, + ) + return + + self.schedule_update_ha_state() diff --git a/homeassistant/components/midea_lan/manifest.json b/homeassistant/components/midea_lan/manifest.json new file mode 100644 index 00000000000000..26b8a37fc528e0 --- /dev/null +++ b/homeassistant/components/midea_lan/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "midea_lan", + "name": "Midea LAN", + "codeowners": ["@chemelli74", "@rokam", "@wuwentao"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/midea_lan", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["midealocal"], + "quality_scale": "bronze", + "requirements": ["midea-local==6.10.0"] +} diff --git a/homeassistant/components/midea_lan/quality_scale.yaml b/homeassistant/components/midea_lan/quality_scale.yaml new file mode 100644 index 00000000000000..0ccd33eafc9c93 --- /dev/null +++ b/homeassistant/components/midea_lan/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no action + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: no actions + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/midea_lan/strings.json b/homeassistant/components/midea_lan/strings.json new file mode 100644 index 00000000000000..d7a39a5bb5701c --- /dev/null +++ b/homeassistant/components/midea_lan/strings.json @@ -0,0 +1,123 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "device_auth_failed": "Could not connect with the provided configuration", + "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", + "ip_address_mismatch": "The IP address does not match the discovered device", + "login_failed": "Could not log in to the selected cloud server", + "no_devices": "No devices found", + "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", + "type_mismatch": "The type does not match the discovered device" + }, + "step": { + "auth_method": { + "data": { + "login_mode": "Login mode" + }, + "data_description": { + "login_mode": "How to authenticate with the Midea cloud" + }, + "description": "Choose how you want to authenticate.", + "title": "Authentication" + }, + "auto": { + "data": { + "device": "Device" + }, + "data_description": { + "device": "Select the discovered device to configure" + }, + "title": "Select a discovered device" + }, + "list": { + "description": "{table}", + "title": "Discovered appliances" + }, + "login_credentials": { + "data": { + "account": "Account", + "password": "[%key:common::config_flow::data::password%]", + "server": "Server" + }, + "data_description": { + "account": "Your Midea cloud account username or email address", + "password": "Your Midea cloud account password", + "server": "The Midea cloud server for your region" + }, + "title": "Cloud login" + }, + "manually": { + "data": { + "device_id": "Device ID", + "ip_address": "[%key:common::config_flow::data::ip%]", + "key": "Key", + "model": "Model", + "port": "[%key:common::config_flow::data::port%]", + "protocol": "Protocol", + "subtype": "Subtype", + "token": "Token", + "type": "Type" + }, + "data_description": { + "device_id": "The device ID of your Midea appliance", + "ip_address": "The local IP address of your Midea appliance", + "key": "The key of your Midea appliance (hexadecimal string)", + "model": "The model of your Midea appliance", + "port": "The local port used by your Midea appliance", + "protocol": "The protocol used by your Midea appliance", + "subtype": "The subtype of your Midea appliance", + "token": "The token of your Midea appliance (hexadecimal string)", + "type": "The type of your Midea appliance" + }, + "title": "Configure manually" + }, + "search": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "Enter 'auto' to scan your network, or enter a specific IP address" + }, + "title": "Search devices" + }, + "user": { + "menu_options": { + "list": "List all appliances only", + "manually": "Configure manually", + "search": "Search automatically" + }, + "title": "Set up Midea LAN" + } + } + }, + "entity": { + "climate": { + "climate_zone1": { + "name": "Zone 1 thermostat" + }, + "climate_zone2": { + "name": "Zone 2 thermostat" + } + } + }, + "exceptions": { + "unsupported_hvac_mode": { + "message": "HVAC mode {hvac_mode} is not supported by this device." + } + }, + "selector": { + "login_mode": { + "options": { + "account": "Your account credentials", + "preset": "Preset cloud credentials" + } + } + } +} diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 174b8686a4aaa9..2a71da83ac0847 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -145,8 +145,8 @@ async def async_handle_event(self, event_message: EventMessage) -> None: return _LOGGER.debug("Event Update %s", events.keys()) device_registry = dr.async_get(self._hass) - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, device_id)} + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, device_id), self._config_entry.entry_id ) if not device_entry: return @@ -273,7 +273,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool subscriber.cache_policy.event_cache_size = EVENT_MEDIA_CACHE_SIZE subscriber.cache_policy.fetch = True # Use disk backed event media store - subscriber.cache_policy.store = await async_get_media_event_store(hass, subscriber) + subscriber.cache_policy.store = await async_get_media_event_store( + hass, entry, subscriber + ) subscriber.cache_policy.transcoder = await async_get_transcoder(hass) # The device manager has a single change callback. When the change diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 9108370da3d3ef..7408a09ede7392 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -76,24 +76,15 @@ def suggested_area(self) -> str | None: return None -@callback -def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]: - """Return a mapping of all nest devices for all config entries.""" - return { - device.name: device - for config_entry in hass.config_entries.async_loaded_entries(DOMAIN) - for device in config_entry.runtime_data.device_manager.devices.values() - } - - @callback def async_nest_devices_by_device_id(hass: HomeAssistant) -> Mapping[str, Device]: """Return a mapping of all nest devices by HA device id.""" device_registry = dr.async_get(hass) devices = {} - for nest_device_id, device in async_nest_devices(hass).items(): - if device_entry := device_registry.async_get_device( - identifiers={(DOMAIN, nest_device_id)} - ): - devices[device_entry.id] = device + for config_entry in hass.config_entries.async_loaded_entries(DOMAIN): + for device in config_entry.runtime_data.device_manager.devices.values(): + if device_entry := device_registry.async_get_device_by_identifier( + (DOMAIN, device.name), config_entry.entry_id + ): + devices[device_entry.id] = device return devices diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index 12d2fc4855becc..43afee858f390d 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -55,6 +55,7 @@ from .const import DOMAIN from .device_info import NestDeviceInfo, async_nest_devices_by_device_id from .events import EVENT_NAME_MAP, MEDIA_SOURCE_EVENT_TITLE_MAP +from .types import NestConfigEntry _LOGGER = logging.getLogger(__name__) @@ -80,7 +81,7 @@ async def async_get_media_event_store( - hass: HomeAssistant, subscriber: GoogleNestSubscriber + hass: HomeAssistant, config_entry: NestConfigEntry, subscriber: GoogleNestSubscriber ) -> EventMediaStore: """Create the disk backed EventMediaStore.""" media_path = pathlib.Path(hass.config.cache_path(DOMAIN, MEDIA_CACHE_PATH)) @@ -89,7 +90,7 @@ async def async_get_media_event_store( _prepare_media_cache_dir, media_path, legacy_media_path ) store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY, private=True) - return NestEventMediaStore(hass, subscriber, store, str(media_path)) + return NestEventMediaStore(hass, config_entry, subscriber, store, str(media_path)) def _prepare_media_cache_dir( @@ -138,12 +139,14 @@ class NestEventMediaStore(EventMediaStore): def __init__( self, hass: HomeAssistant, + config_entry: NestConfigEntry, subscriber: GoogleNestSubscriber, store: Store[dict[str, Any]], media_path: str, ) -> None: """Initialize NestEventMediaStore.""" self._hass = hass + self._config_entry = config_entry self._subscriber = subscriber self._store = store self._media_path = media_path @@ -284,8 +287,8 @@ async def _get_devices(self) -> Mapping[str, str]: device_manager = await self._subscriber.async_get_device_manager() devices = {} for device in device_manager.devices.values(): - if device_entry := device_registry.async_get_device( - identifiers={(DOMAIN, device.name)} + if device_entry := device_registry.async_get_device_by_identifier( + (DOMAIN, device.name), self._config_entry.entry_id ): devices[device.name] = device_entry.id return devices diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5ef4c22897d6f8..38bc111062352b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -472,6 +472,7 @@ "meteoclimatic", "metoffice", "microbees", + "midea_lan", "miele", "mikrotik", "mill", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5b860e42de22cd..b6852cf627c12e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4341,6 +4341,12 @@ } } }, + "midea_lan": { + "name": "Midea LAN", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "miele": { "name": "Miele", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index e132511021551c..fbbd0ba93bc4ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1588,6 +1588,9 @@ micloud==0.5 # homeassistant.components.microbees microBeesPy==0.3.5 +# homeassistant.components.midea_lan +midea-local==6.10.0 + # homeassistant.components.mill mill-local==0.5.0 diff --git a/tests/components/midea_lan/__init__.py b/tests/components/midea_lan/__init__.py new file mode 100644 index 00000000000000..7f1b7067374718 --- /dev/null +++ b/tests/components/midea_lan/__init__.py @@ -0,0 +1,24 @@ +"""Tests for the Midea LAN integration.""" + +from unittest.mock import patch + +from homeassistant.core import HomeAssistant + +from .conftest import DummyDevice + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, device: DummyDevice +) -> None: + """Set up a Midea LAN config entry backed by a fake device.""" + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.midea_lan.device_selector", + return_value=device, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() diff --git a/tests/components/midea_lan/conftest.py b/tests/components/midea_lan/conftest.py new file mode 100644 index 00000000000000..2e346acce0aafc --- /dev/null +++ b/tests/components/midea_lan/conftest.py @@ -0,0 +1,170 @@ +"""Fixtures for Midea LAN tests.""" + +from collections.abc import Callable, Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from midealocal.const import DeviceType +import pytest + +from homeassistant.components.midea_lan.const import CONF_KEY, CONF_SUBTYPE, DOMAIN +from homeassistant.const import CONF_NAME, CONF_TOKEN, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import ( + BASE_DATA, + TEST_DEVICE_ID, + TEST_KEY, + TEST_MODEL, + TEST_NAME, + TEST_SUBTYPE, + TEST_TOKEN, +) + +from tests.common import MockConfigEntry + + +class DummyDevice: + """Shared fake Midea device for tests.""" + + def __init__( + self, + device_type: int, + *, + attributes: dict | None = None, + ) -> None: + """Initialize fake device.""" + self.device_type = device_type + self.device_id = TEST_DEVICE_ID + self.name = TEST_NAME + self.model = TEST_MODEL + self.subtype = TEST_SUBTYPE + self.available = False + self.attributes = attributes or {} + self._callbacks: list[Callable] = [] + self.calls: list[tuple] = [] + self.temperature_step = 1 + self.fan_modes = ["Low", "Medium", "High", "Auto"] + self.modes = [ + "Auto", + "ECO", + "Sleep", + "Anti-freezing", + "Comfort", + "Constant-temperature", + "Normal", + "Fast-heating", + "Standby", + ] + + def register_update(self, callback: Callable) -> None: + """Record update callback registration.""" + self._callbacks.append(callback) + + def unregister_update(self, callback: Callable) -> None: + """Record update callback removal.""" + self._callbacks.remove(callback) + + def notify_update(self, status: dict[str, Any]) -> None: + """Notify all registered callbacks with new state.""" + for callback in self._callbacks.copy(): + callback(status) + + def get_attribute(self, attr: str) -> Any: + """Return attribute value.""" + return self.attributes.get(attr) + + def set_attribute(self, attr: str, value: Any) -> None: + """Record set attribute call.""" + self.calls.append(("set_attribute", attr, value)) + + def set_target_temperature(self, **kwargs: Any) -> None: + """Record set target temperature call.""" + self.calls.append(("set_target_temperature", kwargs)) + + def set_swing(self, **kwargs: Any) -> None: + """Record set swing call.""" + self.calls.append(("set_swing", kwargs)) + + def set_mode(self, zone: int, mode: int) -> None: + """Record set mode call.""" + self.calls.append(("set_mode", zone, mode)) + + def connect(self, check_protocol: bool = False) -> bool: + """Record connect call and mirror midealocal's availability handling.""" + self.calls.append(("connect", check_protocol)) + self.available = check_protocol + return check_protocol + + def open(self) -> None: + """Record open call.""" + self.calls.append(("open",)) + + def close(self) -> None: + """Record close call.""" + self.calls.append(("close",)) + + def close_socket(self) -> None: + """Record close_socket call.""" + self.calls.append(("close_socket",)) + + +def default_ac_device() -> DummyDevice: + """Return a default AC device for tests.""" + return DummyDevice( + DeviceType.AC, + attributes={ + "power": True, + "mode": 1, + "target_temperature": 22.0, + "indoor_temperature": 21.0, + "fan_speed": 103, + "swing_vertical": True, + "swing_horizontal": True, + "indoor_humidity": 50, + }, + ) + + +def entity_entries( + hass: HomeAssistant, entry: MockConfigEntry +) -> dict[str, er.RegistryEntry]: + """Return entity registry entries for a config entry, keyed by unique id.""" + entity_registry = er.async_get(hass) + return { + entity_entry.unique_id: entity_entry + for entity_entry in er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + } + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Prevent loading the integration during config flow tests.""" + with patch( + "homeassistant.components.midea_lan.async_setup_entry", + return_value=True, + ) as mock_entry: + yield mock_entry + + +@pytest.fixture +def mock_config_entry() -> Callable[[DummyDevice], MockConfigEntry]: + """Return a function that creates a mock config entry for a given device.""" + + def _create(device: DummyDevice) -> MockConfigEntry: + return MockConfigEntry( + domain=DOMAIN, + data={ + **BASE_DATA, + CONF_TYPE: device.device_type, + CONF_NAME: TEST_NAME, + CONF_TOKEN: TEST_TOKEN, + CONF_KEY: TEST_KEY, + CONF_SUBTYPE: TEST_SUBTYPE, + }, + ) + + return _create diff --git a/tests/components/midea_lan/const.py b/tests/components/midea_lan/const.py new file mode 100644 index 00000000000000..a0c7de5d552c8d --- /dev/null +++ b/tests/components/midea_lan/const.py @@ -0,0 +1,49 @@ +"""Constants for Midea LAN tests.""" + +from midealocal.const import ProtocolVersion + +from homeassistant.components.midea_lan.const import CONF_KEY, CONF_SUBTYPE +from homeassistant.components.midea_lan.device_catalog import MIDEA_DEVICE_NAMES +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_IP_ADDRESS, + CONF_MODEL, + CONF_PORT, + CONF_PROTOCOL, + CONF_TOKEN, + CONF_TYPE, +) + +TEST_DEVICE_ID = 12345678 +TEST_IP_ADDRESS = "1.1.1.1" +TEST_KEY = "bb" * 16 +TEST_MODEL = "MSAGBU-09HRFN8" +TEST_NAME = "Bedroom AC" +TEST_PORT = 6444 +TEST_PROTOCOL = ProtocolVersion.V3 +TEST_SUBTYPE = 0 +TEST_TOKEN = "aa" * 16 +TEST_TYPE = next(iter(MIDEA_DEVICE_NAMES)) + +BASE_DATA = { + CONF_DEVICE_ID: TEST_DEVICE_ID, + CONF_IP_ADDRESS: TEST_IP_ADDRESS, + CONF_PORT: TEST_PORT, + CONF_MODEL: TEST_MODEL, + CONF_PROTOCOL: TEST_PROTOCOL, +} + +DISCOVERY_RESULT = { + TEST_DEVICE_ID: { + **BASE_DATA, + CONF_TYPE: TEST_TYPE, + } +} + +EXTENDED_DATA = { + **BASE_DATA, + CONF_TYPE: TEST_TYPE, + CONF_SUBTYPE: TEST_SUBTYPE, + CONF_TOKEN: TEST_TOKEN, + CONF_KEY: TEST_KEY, +} diff --git a/tests/components/midea_lan/snapshots/test_climate.ambr b/tests/components/midea_lan/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..dc3eb5ecf0c597 --- /dev/null +++ b/tests/components/midea_lan/snapshots/test_climate.ambr @@ -0,0 +1,541 @@ +# serializer version: 1 +# name: test_climate_state_snapshot[ac][climate.bedroom_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'silent', + 'low', + 'medium', + 'high', + 'full', + 'auto', + ]), + : list([ + , + , + , + , + , + , + ]), + : 30.0, + : 16.0, + : list([ + 'none', + 'comfort', + 'eco', + 'boost', + 'sleep', + 'away', + ]), + : list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bedroom_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'midea_lan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345678_climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state_snapshot[ac][climate.bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 50.0, + : 21.0, + : 'auto', + : list([ + 'silent', + 'low', + 'medium', + 'high', + 'full', + 'auto', + ]), + : 'Bedroom AC', + : list([ + , + , + , + , + , + , + ]), + : 30.0, + : 16.0, + : 'none', + : list([ + 'none', + 'comfort', + 'eco', + 'boost', + 'sleep', + 'away', + ]), + : , + : 'both', + : list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + : 1.0, + : 22.0, + }), + 'context': , + 'entity_id': 'climate.bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_climate_state_snapshot[c3][climate.bedroom_ac_zone_1_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + , + ]), + : 30.0, + : 16.0, + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bedroom_ac_zone_1_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zone 1 thermostat', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 1 thermostat', + 'platform': 'midea_lan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'climate_zone1', + 'unique_id': '12345678_climate_zone1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state_snapshot[c3][climate.bedroom_ac_zone_1_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 21.5, + : 'Bedroom AC Zone 1 thermostat', + : list([ + , + , + , + , + ]), + : 30.0, + : 16.0, + : , + : 1.0, + : 22.0, + }), + 'context': , + 'entity_id': 'climate.bedroom_ac_zone_1_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_climate_state_snapshot[c3][climate.bedroom_ac_zone_2_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + , + ]), + : 29.0, + : 17.0, + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bedroom_ac_zone_2_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zone 2 thermostat', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Zone 2 thermostat', + 'platform': 'midea_lan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'climate_zone2', + 'unique_id': '12345678_climate_zone2', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state_snapshot[c3][climate.bedroom_ac_zone_2_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 21.5, + : 'Bedroom AC Zone 2 thermostat', + : list([ + , + , + , + , + ]), + : 29.0, + : 17.0, + : , + : 0.5, + : 23.0, + }), + 'context': , + 'entity_id': 'climate.bedroom_ac_zone_2_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate_state_snapshot[cc][climate.bedroom_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'Low', + 'Medium', + 'High', + 'Auto', + ]), + : list([ + , + , + , + , + , + , + ]), + : 30, + : 16, + : list([ + 'none', + 'sleep', + 'eco', + ]), + : list([ + 'off', + 'on', + ]), + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bedroom_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'midea_lan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345678_climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state_snapshot[cc][climate.bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : None, + : 'High', + : list([ + 'Low', + 'Medium', + 'High', + 'Auto', + ]), + : 'Bedroom AC', + : list([ + , + , + , + , + , + , + ]), + : 30, + : 16, + : 'none', + : list([ + 'none', + 'sleep', + 'eco', + ]), + : , + : 'on', + : list([ + 'off', + 'on', + ]), + : 0.5, + : None, + }), + 'context': , + 'entity_id': 'climate.bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_climate_state_snapshot[cf][climate.bedroom_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + , + , + ]), + : 30.0, + : 16.0, + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bedroom_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'midea_lan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345678_climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state_snapshot[cf][climate.bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 22.0, + : 'Bedroom AC', + : list([ + , + , + , + , + ]), + : 30.0, + : 16.0, + : , + : 1, + : None, + }), + 'context': , + 'entity_id': 'climate.bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_state_snapshot[fb][climate.bedroom_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + , + , + ]), + : 35, + : 5, + : list([ + 'Auto', + 'ECO', + 'Sleep', + 'Anti-freezing', + 'Comfort', + 'Constant-temperature', + 'Normal', + 'Fast-heating', + 'Standby', + ]), + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bedroom_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'midea_lan', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '12345678_climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state_snapshot[fb][climate.bedroom_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 20.0, + : 'Bedroom AC', + : list([ + , + , + ]), + : 35, + : 5, + : 'Comfort', + : list([ + 'Auto', + 'ECO', + 'Sleep', + 'Anti-freezing', + 'Comfort', + 'Constant-temperature', + 'Normal', + 'Fast-heating', + 'Standby', + ]), + : , + : 1, + : None, + }), + 'context': , + 'entity_id': 'climate.bedroom_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/midea_lan/test_climate.py b/tests/components/midea_lan/test_climate.py new file mode 100644 index 00000000000000..39b95d374950a2 --- /dev/null +++ b/tests/components/midea_lan/test_climate.py @@ -0,0 +1,1465 @@ +"""Tests for midea_lan climate.py.""" + +from collections.abc import Callable +from typing import Any + +from midealocal.const import DeviceType +from midealocal.devices.ac import DeviceAttributes as ACAttributes +from midealocal.devices.c3.const import DeviceAttributes as C3Attributes +from midealocal.devices.cc import DeviceAttributes as CCAttributes +from midealocal.devices.cf import DeviceAttributes as CFAttributes +from midealocal.devices.fb import DeviceAttributes as FBAttributes +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_STEP, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SWING_BOTH, + SWING_ON, + SWING_VERTICAL, + HVACMode, +) +from homeassistant.components.midea_lan.climate import FAN_FULL_SPEED, FAN_SILENT +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import DummyDevice, entity_entries +from .const import TEST_DEVICE_ID + +from tests.common import MockConfigEntry, snapshot_platform + + +async def _assert_service_calls( + hass: HomeAssistant, + entity_id: str, + service: str, + service_data: dict, + expected_calls: list[tuple], + device: DummyDevice, +) -> None: + """Call a climate service and assert the fake device recorded the right call.""" + device.calls.clear() + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + assert device.calls == expected_calls + + +async def test_midea_ac_climate_setup_and_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test AC climate entities are created and service calls reach the device.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.comfort_mode: False, + ACAttributes.eco_mode: False, + ACAttributes.boost_mode: False, + ACAttributes.sleep_mode: False, + ACAttributes.frost_protect: False, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + ACAttributes.indoor_humidity: 50, + }, + ) + 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"] + + state = hass.states.get(entity_entry.entity_id) + assert state is not None + assert state.state == HVACMode.AUTO + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 50.0 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.0 + assert state.attributes[ATTR_FAN_MODE] == "auto" + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + ] + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 16 + assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + assert state.attributes[ATTR_SWING_MODE] == SWING_BOTH + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 1.0 + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_TURN_OFF, + {}, + [("set_attribute", ACAttributes.power, False)], + device, + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_TURN_ON, + {}, + [("set_attribute", ACAttributes.power, True)], + device, + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 23.1, "hvac_mode": HVACMode.COOL}, + [ + ( + "set_target_temperature", + {"target_temperature": 23.1, "mode": 2, "zone": None}, + ) + ], + device, + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_HVAC_MODE, + {"hvac_mode": HVACMode.HEAT}, + [("set_attribute", ACAttributes.mode, 4)], + device, + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: FAN_LOW}, + [("set_attribute", ACAttributes.fan_speed, 40)], + device, + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_SWING_MODE, + {ATTR_SWING_MODE: SWING_VERTICAL}, + [("set_swing", {"swing_vertical": True, "swing_horizontal": False})], + device, + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_COMFORT}, + [("set_attribute", ACAttributes.comfort_mode, True)], + device, + ) + device.attributes.update( + { + ACAttributes.comfort_mode: True, + ACAttributes.eco_mode: False, + ACAttributes.boost_mode: False, + ACAttributes.sleep_mode: False, + ACAttributes.frost_protect: False, + } + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_ECO}, + [("set_attribute", ACAttributes.eco_mode, True)], + device, + ) + device.attributes.update( + { + ACAttributes.comfort_mode: True, + ACAttributes.eco_mode: False, + ACAttributes.boost_mode: False, + ACAttributes.sleep_mode: False, + ACAttributes.frost_protect: False, + } + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_NONE}, + [("set_attribute", ACAttributes.comfort_mode, False)], + device, + ) + + +async def test_ac_min_max_temperature_from_device( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test AC min/max temperature are read from the device attributes.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + ACAttributes.min_temperature: 17, + ACAttributes.max_temperature: 26, + }, + ) + 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"] + entity = hass.data[CLIMATE_DOMAIN].get_entity(entity_entry.entity_id) + + assert entity is not None + assert entity.min_temp == 17.0 + assert entity.max_temp == 26.0 + + +async def test_midea_cc_climate_setup_and_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test CC climate entities are created and exposed through hass.states.""" + device = DummyDevice( + DeviceType.CC, + attributes={ + CCAttributes.power: True, + CCAttributes.mode: 5, + CCAttributes.fan_speed: "High", + CCAttributes.temperature_precision: 0.5, + CCAttributes.swing: True, + }, + ) + 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"] + + 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_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.AUTO, + ] + assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + assert state.attributes[ATTR_SWING_MODE] == SWING_ON + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.5 + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: "Low"}, + [("set_attribute", CCAttributes.fan_speed, "Low")], + device, + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_SWING_MODE, + {ATTR_SWING_MODE: SWING_ON}, + [("set_attribute", CCAttributes.swing, True)], + device, + ) + + +async def test_midea_cf_climate_setup_and_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test CF climate entities are created and control calls are routed.""" + device = DummyDevice( + DeviceType.CF, + attributes={ + "power": True, + "mode": 2, + CFAttributes.min_temperature: 16, + CFAttributes.max_temperature: 30, + CFAttributes.current_temperature: 22, + CFAttributes.target_temperature: 20, + }, + ) + 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"] + + state = hass.states.get(entity_entry.entity_id) + assert state is not None + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.0 + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.HEAT, + ] + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 16 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 1.0 + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 24.2, "hvac_mode": HVACMode.OFF}, + [("set_attribute", CFAttributes.power, False)], + device, + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_HVAC_MODE, + {"hvac_mode": HVACMode.HEAT}, + [ + ( + "set_target_temperature", + {"target_temperature": 20.0, "mode": 3}, + ) + ], + device, + ) + + +async def test_midea_c3_climate_setup_and_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test C3 climate entities are created and zone-specific services work.""" + device = DummyDevice( + DeviceType.C3, + attributes={ + C3Attributes.zone_temp_type: [True, False], + C3Attributes.temperature_min: [16, 17], + C3Attributes.temperature_max: [30, 29], + C3Attributes.mode: 1, + C3Attributes.zone1_power: True, + C3Attributes.target_temperature: [22, 23], + C3Attributes.temp_tw_out: 21.5, + }, + ) + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + entries_by_unique_id = entity_entries(hass, config_entry) + + zone1 = entries_by_unique_id[f"{TEST_DEVICE_ID}_climate_zone1"] + zone2 = entries_by_unique_id[f"{TEST_DEVICE_ID}_climate_zone2"] + assert zone2.disabled_by == er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(zone2.entity_id) is None + + state = hass.states.get(zone1.entity_id) + assert state is not None + assert state.state == HVACMode.AUTO + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.5 + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.HEAT, + ] + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 16 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 1.0 + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + + await _assert_service_calls( + hass, + zone1.entity_id, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 21.4, "hvac_mode": HVACMode.COOL}, + [ + ( + "set_target_temperature", + {"target_temperature": 21.4, "mode": 2, "zone": 0}, + ) + ], + device, + ) + await _assert_service_calls( + hass, + zone1.entity_id, + SERVICE_SET_HVAC_MODE, + {"hvac_mode": HVACMode.OFF}, + [("set_attribute", C3Attributes.zone1_power, False)], + device, + ) + await _assert_service_calls( + hass, + zone1.entity_id, + SERVICE_SET_HVAC_MODE, + {"hvac_mode": HVACMode.HEAT}, + [("set_mode", 0, 3)], + device, + ) + + +async def test_midea_fb_climate_setup_and_services( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test FB climate entities are created and preset calls are routed.""" + device = DummyDevice( + DeviceType.FB, + attributes={ + FBAttributes.mode: "Comfort", + FBAttributes.power: True, + FBAttributes.current_temperature: 20, + }, + ) + 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"] + + state = hass.states.get(entity_entry.entity_id) + assert state is not None + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.OFF, HVACMode.HEAT] + assert state.attributes[ATTR_MAX_TEMP] == 35 + assert state.attributes[ATTR_MIN_TEMP] == 5 + assert state.attributes[ATTR_PRESET_MODE] == "Comfort" + assert state.attributes[ATTR_TARGET_TEMP_STEP] == 1.0 + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 24.2, "hvac_mode": HVACMode.OFF}, + [("set_attribute", FBAttributes.power, False)], + device, + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_HVAC_MODE, + {"hvac_mode": HVACMode.HEAT}, + [("set_attribute", FBAttributes.power, True)], + device, + ) + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: "ECO"}, + [("set_attribute", FBAttributes.mode, "ECO")], + device, + ) + + +@pytest.mark.parametrize( + ("fan_speed", "expected_fan_mode"), + [ + pytest.param(101, "auto", id="just_above_auto_threshold"), + pytest.param(100, "full", id="auto_threshold_falls_to_full"), + pytest.param(80, "high", id="full_threshold_falls_to_high"), + pytest.param(60, "medium", id="high_threshold_falls_to_medium"), + pytest.param(40, "low", id="medium_threshold_falls_to_low"), + pytest.param(20, "silent", id="low_threshold_falls_to_silent"), + pytest.param(0, "silent", id="silent_fallback"), + ], +) +async def test_ac_fan_mode_read_thresholds( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + fan_speed: int, + expected_fan_mode: str, +) -> None: + """Test AC fan mode read mapping across every numeric bucket boundary.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: fan_speed, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + ACAttributes.indoor_humidity: 50, + }, + ) + 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"] + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.attributes[ATTR_FAN_MODE] == expected_fan_mode + + +@pytest.mark.parametrize( + ("fan_mode", "expected_speed"), + [ + pytest.param(FAN_SILENT, 20, id="silent"), + pytest.param(FAN_LOW, 40, id="low"), + pytest.param(FAN_MEDIUM, 60, id="medium"), + pytest.param(FAN_HIGH, 80, id="high"), + pytest.param(FAN_FULL_SPEED, 100, id="full"), + pytest.param(FAN_AUTO, 102, id="auto"), + ], +) +async def test_ac_fan_mode_write_speeds( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + fan_mode: str, + expected_speed: int, +) -> None: + """Test AC set_fan_mode writes the numeric speed for every fan mode.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + ) + 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"] + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: fan_mode}, + [("set_attribute", ACAttributes.fan_speed, expected_speed)], + device, + ) + + +async def test_ac_set_temperature_without_hvac_mode( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test set_temperature without hvac_mode leaves the protocol mode unset.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + ) + 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"] + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 23.0}, + [ + ( + "set_target_temperature", + {"target_temperature": 23.0, "mode": None, "zone": None}, + ) + ], + device, + ) + + +@pytest.mark.parametrize( + ("humidity", "expected_humidity"), + [ + pytest.param(50, 50.0, id="normal"), + pytest.param(0, None, id="invalid_zero"), + pytest.param(0xFF, None, id="invalid_ff"), + ], +) +async def test_ac_humidity_filtering( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + humidity: int, + expected_humidity: float | None, +) -> None: + """Test AC humidity filtering for invalid sensor values.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + ACAttributes.indoor_humidity: humidity, + }, + ) + 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"] + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == expected_humidity + + +async def test_base_set_temperature_without_target_noop( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test set_temperature without ATTR_TEMPERATURE is ignored.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + ) + 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"] + entity = hass.data[CLIMATE_DOMAIN].get_entity(entity_entry.entity_id) + + device.calls.clear() + assert entity is not None + entity.set_temperature() + assert device.calls == [] + + +async def test_set_preset_mode_none_when_already_none_is_noop( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test setting preset mode to none while already none writes nothing.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.comfort_mode: False, + ACAttributes.eco_mode: False, + ACAttributes.boost_mode: False, + ACAttributes.sleep_mode: False, + ACAttributes.frost_protect: False, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + ) + 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"] + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: PRESET_NONE}, + [], + device, + ) + + +async def test_ac_set_hvac_mode_off_calls_power_off( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test AC HVAC off delegates to turn_off.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + ) + 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"] + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_HVAC_MODE, + {"hvac_mode": HVACMode.OFF}, + [("set_attribute", ACAttributes.power, False)], + device, + ) + + +@pytest.mark.parametrize( + ("attributes", "expected_state"), + [ + pytest.param( + { + ACAttributes.power: True, + ACAttributes.mode: 999, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + "unknown", + id="invalid_mode", + ), + pytest.param( + { + ACAttributes.power: True, + ACAttributes.mode: 0, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + "unknown", + id="protocol_mode_zero_while_powered_on", + ), + pytest.param( + { + ACAttributes.power: False, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + HVACMode.OFF, + id="power_off", + ), + pytest.param( + { + ACAttributes.power: True, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + "unknown", + id="missing_mode", + ), + pytest.param( + { + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + "unknown", + id="missing_power", + ), + ], +) +async def test_ac_hvac_mode_branches( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + attributes: dict[str, Any], + expected_state: str, +) -> None: + """Test AC hvac_mode across power/mode edge cases. + + Protocol mode 0 is reserved for the OFF entry in hvac_modes and is + never sent by the device while powered on; if the sub-protocol decoder + reports it anyway it must not be misread as an explicit OFF request. + """ + device = DummyDevice(DeviceType.AC, attributes=attributes) + 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"] + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.state == expected_state + + +async def test_ac_fan_mode_invalid_type_returns_none( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test AC fan_mode returns None for an unexpected attribute type.""" + device = DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.fan_speed: "auto", + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + }, + ) + 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"] + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.attributes.get(ATTR_FAN_MODE) is None + + +async def test_cf_min_max_temperature_from_device( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test CF min/max temperature are read from the device attributes.""" + device = DummyDevice( + DeviceType.CF, + attributes={ + "power": True, + "mode": 2, + CFAttributes.min_temperature: 5, + CFAttributes.max_temperature: 55, + CFAttributes.current_temperature: 22, + }, + ) + 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"] + entity = hass.data[CLIMATE_DOMAIN].get_entity(entity_entry.entity_id) + + assert entity is not None + assert entity.min_temp == 5.0 + assert entity.max_temp == 55.0 + + +async def test_set_temperature_unsupported_hvac_mode_raises( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test set_temperature with an hvac_mode unsupported by the device raises.""" + device = DummyDevice( + DeviceType.CF, + attributes={ + "power": True, + "mode": 2, + CFAttributes.min_temperature: 16, + CFAttributes.max_temperature: 30, + CFAttributes.current_temperature: 22, + }, + ) + 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"] + + device.calls.clear() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_entry.entity_id, + ATTR_TEMPERATURE: 23.0, + "hvac_mode": HVACMode.DRY, + }, + blocking=True, + ) + assert device.calls == [] + + +async def test_c3_temperature_fallback_and_turn_on( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test C3 fallback temperatures and turn_on path for zone power.""" + device = DummyDevice( + DeviceType.C3, + attributes={ + C3Attributes.zone_temp_type: [True], + C3Attributes.temperature_min: [5, 5], + C3Attributes.temperature_max: [55, 55], + C3Attributes.mode: 1, + C3Attributes.zone1_power: True, + C3Attributes.target_temperature: [22], + C3Attributes.temp_tw_out: 21.5, + }, + ) + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + zone1 = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_climate_zone1"] + entity = hass.data[CLIMATE_DOMAIN].get_entity(zone1.entity_id) + + assert entity is not None + assert entity.min_temp == 5.0 + assert entity.max_temp == 55.0 + + await _assert_service_calls( + hass, + zone1.entity_id, + SERVICE_TURN_ON, + {}, + [("set_attribute", C3Attributes.zone1_power, True)], + device, + ) + + +async def test_c3_zero_temperature_limits_use_fallback( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test C3 min/max fall back to defaults when the device reports [0.0, 0.0]. + + Some devices report [0.0, 0.0] for the temperature limits when in + water-mode combined with auto/cool, which must not be treated as a + valid (and therefore unusable) range. + """ + device = DummyDevice( + DeviceType.C3, + attributes={ + C3Attributes.zone_temp_type: [True, False], + C3Attributes.temperature_min: [0.0, 0.0], + C3Attributes.temperature_max: [0.0, 0.0], + C3Attributes.mode: 1, + C3Attributes.zone1_power: True, + C3Attributes.target_temperature: [22, 23], + C3Attributes.temp_tw_out: 21.5, + }, + ) + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + zone1 = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_climate_zone1"] + entity = hass.data[CLIMATE_DOMAIN].get_entity(zone1.entity_id) + + assert entity is not None + assert entity.min_temp == 5.0 + assert entity.max_temp == 60.0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_c3_zero_temperature_limit_uses_fallback_per_zone( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test C3 min/max fall back per zone when only one zone reports 0.0. + + A zone stuck at 0.0 must fall back independently, even when the other + zone reports a valid, populated value. + """ + device = DummyDevice( + DeviceType.C3, + attributes={ + C3Attributes.zone_temp_type: [True, False], + C3Attributes.temperature_min: [0.0, 10.0], + C3Attributes.temperature_max: [0.0, 45.0], + C3Attributes.mode: 1, + C3Attributes.zone1_power: True, + C3Attributes.target_temperature: [22, 23], + C3Attributes.temp_tw_out: 21.5, + }, + ) + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + zone1 = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_climate_zone1"] + zone2 = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_climate_zone2"] + entity1 = hass.data[CLIMATE_DOMAIN].get_entity(zone1.entity_id) + entity2 = hass.data[CLIMATE_DOMAIN].get_entity(zone2.entity_id) + + assert entity1 is not None + assert entity1.min_temp == 5.0 + assert entity1.max_temp == 60.0 + assert entity2 is not None + assert entity2.min_temp == 10.0 + assert entity2.max_temp == 45.0 + + +async def test_c3_temperature_limit_list_too_short_uses_fallback( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test C3 min/max fall back when the reported list has fewer than 2 entries.""" + device = DummyDevice( + DeviceType.C3, + attributes={ + C3Attributes.zone_temp_type: [True], + C3Attributes.temperature_min: [16], + C3Attributes.temperature_max: [30], + C3Attributes.mode: 1, + C3Attributes.zone1_power: True, + C3Attributes.target_temperature: [22], + C3Attributes.temp_tw_out: 21.5, + }, + ) + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + zone1 = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_climate_zone1"] + entity = hass.data[CLIMATE_DOMAIN].get_entity(zone1.entity_id) + + assert entity is not None + assert entity.min_temp == 5.0 + assert entity.max_temp == 60.0 + + +@pytest.mark.parametrize( + ("attributes", "expected_state"), + [ + pytest.param( + { + C3Attributes.zone1_power: True, + C3Attributes.mode: 999, + C3Attributes.temp_tw_out: 21.5, + }, + "unknown", + id="invalid_mode", + ), + pytest.param( + { + C3Attributes.zone1_power: True, + C3Attributes.mode: 0, + C3Attributes.temp_tw_out: 21.5, + }, + "unknown", + id="protocol_mode_zero_while_powered_on", + ), + pytest.param( + { + C3Attributes.zone1_power: False, + C3Attributes.mode: 1, + C3Attributes.temp_tw_out: 21.5, + }, + HVACMode.OFF, + id="power_off", + ), + pytest.param( + { + C3Attributes.zone1_power: True, + C3Attributes.temp_tw_out: 21.5, + }, + "unknown", + id="missing_mode", + ), + pytest.param( + { + C3Attributes.zone_temp_type: [True], + C3Attributes.mode: 1, + C3Attributes.target_temperature: [22], + C3Attributes.temp_tw_out: 21.5, + }, + "unknown", + id="missing_power", + ), + ], +) +async def test_c3_zone_hvac_mode_branches( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + attributes: dict[str, Any], + expected_state: str, +) -> None: + """Test C3 zone hvac_mode across power/mode edge cases.""" + device = DummyDevice(DeviceType.C3, attributes=attributes) + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + zone1 = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_climate_zone1"] + + assert (state := hass.states.get(zone1.entity_id)) + assert state.state == expected_state + + +async def test_cf_set_hvac_mode_falls_back_to_min_temp( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test CF set_hvac_mode falls back to min_temp when target_temperature is unset.""" + device = DummyDevice( + DeviceType.CF, + attributes={ + "power": True, + "mode": 2, + CFAttributes.min_temperature: 16, + CFAttributes.max_temperature: 30, + CFAttributes.current_temperature: 22, + }, + ) + 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"] + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_HVAC_MODE, + {"hvac_mode": HVACMode.HEAT}, + [ + ( + "set_target_temperature", + {"target_temperature": 16.0, "mode": 3}, + ) + ], + device, + ) + + +async def test_fb_set_hvac_off_calls_turn_off( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test FB HVAC off delegates to turn_off.""" + device = DummyDevice( + DeviceType.FB, + attributes={ + FBAttributes.mode: "Comfort", + FBAttributes.power: True, + FBAttributes.current_temperature: 20, + }, + ) + 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"] + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_HVAC_MODE, + {"hvac_mode": HVACMode.OFF}, + [("set_attribute", FBAttributes.power, False)], + device, + ) + + +async def test_fb_set_temperature_with_heat_mode_turns_on_when_off( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test FB set_temperature turns the device on when off and hvac_mode is heat.""" + device = DummyDevice( + DeviceType.FB, + attributes={ + FBAttributes.mode: "Comfort", + FBAttributes.power: False, + FBAttributes.current_temperature: 20, + }, + ) + 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"] + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 25.0, "hvac_mode": HVACMode.HEAT}, + [ + ("set_attribute", FBAttributes.power, True), + ( + "set_target_temperature", + {"target_temperature": 25.0, "mode": 1, "zone": None}, + ), + ], + device, + ) + + +async def test_cf_set_hvac_mode_off_calls_turn_off( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test CF set_hvac_mode with OFF delegates to turn_off.""" + device = DummyDevice( + DeviceType.CF, + attributes={ + "power": True, + "mode": 2, + CFAttributes.min_temperature: 16, + CFAttributes.max_temperature: 30, + CFAttributes.current_temperature: 22, + }, + ) + 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"] + + await _assert_service_calls( + hass, + entity_entry.entity_id, + SERVICE_SET_HVAC_MODE, + {"hvac_mode": HVACMode.OFF}, + [("set_attribute", CFAttributes.power, False)], + device, + ) + + +async def test_cf_temperature_range_fallback_when_unset( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test CF min/max temperature fall back to defaults when attributes are unset.""" + device = DummyDevice( + DeviceType.CF, + attributes={"power": True, "mode": 2, CFAttributes.current_temperature: 22}, + ) + 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"] + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.attributes[ATTR_MIN_TEMP] == 16.0 + assert state.attributes[ATTR_MAX_TEMP] == 30.0 + + +async def test_cc_fan_and_swing_invalid_types_return_none( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test CC fan_mode/swing_mode return None for unexpected attribute types.""" + device = DummyDevice( + DeviceType.CC, + attributes={ + CCAttributes.power: True, + CCAttributes.mode: 5, + CCAttributes.fan_speed: 1, + CCAttributes.temperature_precision: 0.5, + CCAttributes.swing: "on", + }, + ) + 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"] + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.attributes.get(ATTR_FAN_MODE) is None + assert state.attributes.get(ATTR_SWING_MODE) is None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_c3_zone2_service_calls_address_zone_two( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test C3 zone2 service calls use zone index 1 and the zone2_power attribute.""" + device = DummyDevice( + DeviceType.C3, + attributes={ + C3Attributes.zone_temp_type: [True, False], + C3Attributes.temperature_min: [16, 17], + C3Attributes.temperature_max: [30, 29], + C3Attributes.mode: 1, + C3Attributes.zone1_power: True, + C3Attributes.zone2_power: True, + C3Attributes.target_temperature: [22, 23], + C3Attributes.temp_tw_out: 21.5, + }, + ) + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + zone2 = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_climate_zone2"] + + await _assert_service_calls( + hass, + zone2.entity_id, + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 24.0, "hvac_mode": HVACMode.COOL}, + [ + ( + "set_target_temperature", + {"target_temperature": 24.0, "mode": 2, "zone": 1}, + ) + ], + device, + ) + await _assert_service_calls( + hass, + zone2.entity_id, + SERVICE_SET_HVAC_MODE, + {"hvac_mode": HVACMode.HEAT}, + [("set_mode", 1, 3)], + device, + ) + await _assert_service_calls( + hass, + zone2.entity_id, + SERVICE_TURN_OFF, + {}, + [("set_attribute", C3Attributes.zone2_power, False)], + device, + ) + + +async def test_c3_temperature_fallback_when_attribute_missing( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test C3 min/max/target temperature fall back when attributes are missing.""" + device = DummyDevice( + DeviceType.C3, + attributes={ + C3Attributes.zone1_power: True, + C3Attributes.mode: 1, + C3Attributes.temp_tw_out: 21.5, + }, + ) + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + zone1 = entity_entries(hass, config_entry)[f"{TEST_DEVICE_ID}_climate_zone1"] + entity = hass.data[CLIMATE_DOMAIN].get_entity(zone1.entity_id) + + assert entity is not None + assert entity.min_temp == 5.0 + assert entity.max_temp == 60.0 + assert entity.target_temperature is None + + +async def test_fb_invalid_attribute_types_return_none( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test FB preset_mode/hvac_mode return None for unexpected attribute types.""" + device = DummyDevice( + DeviceType.FB, + attributes={ + FBAttributes.mode: 1, + FBAttributes.power: "on", + FBAttributes.current_temperature: 20, + }, + ) + 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"] + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.attributes.get(ATTR_PRESET_MODE) is None + assert state.state == "unknown" + + +@pytest.mark.parametrize( + "device", + [ + pytest.param( + DummyDevice( + DeviceType.AC, + attributes={ + ACAttributes.power: True, + ACAttributes.mode: 1, + ACAttributes.target_temperature: 22.0, + ACAttributes.indoor_temperature: 21.0, + ACAttributes.comfort_mode: False, + ACAttributes.eco_mode: False, + ACAttributes.boost_mode: False, + ACAttributes.sleep_mode: False, + ACAttributes.frost_protect: False, + ACAttributes.fan_speed: 103, + ACAttributes.swing_vertical: True, + ACAttributes.swing_horizontal: True, + ACAttributes.indoor_humidity: 50, + }, + ), + id="ac", + ), + pytest.param( + DummyDevice( + DeviceType.CC, + attributes={ + CCAttributes.power: True, + CCAttributes.mode: 5, + CCAttributes.fan_speed: "High", + CCAttributes.temperature_precision: 0.5, + CCAttributes.swing: True, + }, + ), + id="cc", + ), + pytest.param( + DummyDevice( + DeviceType.CF, + attributes={ + "power": True, + "mode": 2, + CFAttributes.min_temperature: 16, + CFAttributes.max_temperature: 30, + CFAttributes.current_temperature: 22, + }, + ), + id="cf", + ), + pytest.param( + DummyDevice( + DeviceType.C3, + attributes={ + C3Attributes.zone_temp_type: [True, False], + C3Attributes.temperature_min: [16, 17], + C3Attributes.temperature_max: [30, 29], + C3Attributes.mode: 1, + C3Attributes.zone1_power: True, + C3Attributes.zone2_power: False, + C3Attributes.target_temperature: [22, 23], + C3Attributes.temp_tw_out: 21.5, + }, + ), + id="c3", + ), + pytest.param( + DummyDevice( + DeviceType.FB, + attributes={ + FBAttributes.mode: "Comfort", + FBAttributes.power: True, + FBAttributes.current_temperature: 20, + }, + ), + id="fb", + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate_state_snapshot( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device: DummyDevice, +) -> None: + """Test async_setup_entry creates entities for each device type.""" + config_entry = mock_config_entry(device) + await setup_integration(hass, config_entry, device) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) diff --git a/tests/components/midea_lan/test_config_flow.py b/tests/components/midea_lan/test_config_flow.py new file mode 100644 index 00000000000000..26371fe68e24c2 --- /dev/null +++ b/tests/components/midea_lan/test_config_flow.py @@ -0,0 +1,1785 @@ +"""Tests for the Midea LAN config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from midealocal.const import DeviceType, ProtocolVersion +import pytest + +from homeassistant.components.midea_lan.config_flow import ( + DEFAULT_CLOUD, + LOGIN_MODE_ACCOUNT, + LOGIN_MODE_PRESET, +) +from homeassistant.components.midea_lan.const import ( + CONF_ACCOUNT, + CONF_KEY, + CONF_SERVER, + CONF_SUBTYPE, + DOMAIN, +) +from homeassistant.components.midea_lan.device_catalog import MIDEA_DEVICE_NAMES +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + CONF_DEVICE, + CONF_DEVICE_ID, + CONF_IP_ADDRESS, + CONF_MODEL, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_TOKEN, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import ( + BASE_DATA, + DISCOVERY_RESULT, + EXTENDED_DATA, + TEST_DEVICE_ID, + TEST_IP_ADDRESS, + TEST_KEY, + TEST_MODEL, + TEST_PORT, + TEST_PROTOCOL, + TEST_SUBTYPE, + TEST_TOKEN, + TEST_TYPE, +) + +from tests.common import MockConfigEntry, get_schema_suggested_value + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_manual_flow_success(hass: HomeAssistant) -> None: + """Test a successful manual configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + flow_id = result["flow_id"] + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.discover", + return_value=DISCOVERY_RESULT, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + ) as mock_midea_device, + ): + mock_device = MagicMock() + mock_device.connect.return_value = True + mock_midea_device.return_value = mock_device + + 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" + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={**EXTENDED_DATA}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MIDEA_DEVICE_NAMES[TEST_TYPE] + assert result["data"] == { + CONF_NAME: MIDEA_DEVICE_NAMES[TEST_TYPE], + CONF_DEVICE_ID: TEST_DEVICE_ID, + CONF_TYPE: TEST_TYPE, + CONF_PROTOCOL: TEST_PROTOCOL, + CONF_IP_ADDRESS: TEST_IP_ADDRESS, + CONF_PORT: TEST_PORT, + CONF_MODEL: TEST_MODEL, + CONF_SUBTYPE: TEST_SUBTYPE, + CONF_TOKEN: TEST_TOKEN, + CONF_KEY: TEST_KEY, + } + + +async def test_manual_flow_duplicate_unique_id(hass: HomeAssistant) -> None: + """Test manual flow aborts when device is already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=str(TEST_DEVICE_ID), + version=1, + minor_version=1, + data={CONF_DEVICE_ID: TEST_DEVICE_ID, CONF_IP_ADDRESS: TEST_IP_ADDRESS}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + flow_id = result["flow_id"] + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.discover", + return_value=DISCOVERY_RESULT, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + ) as mock_midea_device, + ): + mock_device = MagicMock() + mock_device.connect.return_value = True + mock_midea_device.return_value = mock_device + + await hass.config_entries.flow.async_configure( + flow_id, + user_input={"next_step_id": "manually"}, + ) + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={**EXTENDED_DATA}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ( + "user_input", + "discover_result", + "connect_return", + "cloud_login_return", + "cloud_keys_return", + "default_keys_return", + "pre_input", + "expected_error", + ), + [ + pytest.param( + {**EXTENDED_DATA, CONF_TOKEN: "zz"}, + None, + None, + True, + {}, + {}, + None, + "invalid_token", + id="invalid_token", + ), + pytest.param( + {**EXTENDED_DATA}, + {}, + None, + True, + {}, + {}, + None, + "invalid_device_ip", + id="discover_empty", + ), + pytest.param( + {**EXTENDED_DATA}, + {TEST_DEVICE_ID + 1: {**BASE_DATA, CONF_TYPE: TEST_TYPE}}, + None, + True, + {}, + {}, + None, + "invalid_device_id_for_ip", + id="discover_id_mismatch", + ), + pytest.param( + {**EXTENDED_DATA}, + { + TEST_DEVICE_ID: { + **BASE_DATA, + CONF_TYPE: TEST_TYPE, + CONF_IP_ADDRESS: "2.2.2.2", + }, + }, + None, + True, + {}, + {}, + None, + "ip_address_mismatch", + id="ip_mismatch", + ), + pytest.param( + {**EXTENDED_DATA}, + { + TEST_DEVICE_ID: { + **BASE_DATA, + CONF_TYPE: TEST_TYPE, + CONF_PROTOCOL: ProtocolVersion.V2, + }, + }, + None, + True, + {}, + {}, + None, + "protocol_mismatch", + id="protocol_mismatch", + ), + pytest.param( + {**EXTENDED_DATA}, + { + TEST_DEVICE_ID: { + **BASE_DATA, + CONF_TYPE: DeviceType.C3, + }, + }, + None, + True, + {}, + {}, + None, + "type_mismatch", + id="type_mismatch", + ), + pytest.param( + {**EXTENDED_DATA}, + {TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE}}, + False, + True, + {}, + {}, + None, + "device_auth_failed", + id="connect_fails", + ), + pytest.param( + {**EXTENDED_DATA, CONF_TOKEN: "", CONF_KEY: ""}, + {TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE}}, + None, + False, + {}, + {}, + None, + "preset_login_failed", + id="preset_login_fails", + ), + pytest.param( + {**EXTENDED_DATA, CONF_TOKEN: "", CONF_KEY: ""}, + {TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE}}, + None, + True, + {}, + {}, + None, + "token_unavailable", + id="no_token_from_cloud", + ), + ], +) +async def test_manual_step_errors( + hass: HomeAssistant, + user_input: dict[str, object], + discover_result: dict[int, dict[str, object]] | None, + connect_return: bool | None, + cloud_login_return: bool, + cloud_keys_return: dict[str, dict[str, str]], + default_keys_return: dict[str, dict[str, str]], + pre_input: dict[str, object] | None, + expected_error: str, +) -> None: + """Test every async_step_manually error branch via one parametrized flow.""" + 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" + + dm = MagicMock() + dm.connect.return_value = connect_return + + cloud = MagicMock() + cloud.login = AsyncMock(return_value=cloud_login_return) + cloud.get_cloud_keys = AsyncMock(return_value=cloud_keys_return) + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.discover", + return_value=discover_result, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + return_value=dm, + ), + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_default_keys", + AsyncMock(return_value=default_keys_return), + ), + ): + if pre_input is not None: + await hass.config_entries.flow.async_configure( + flow_id, + user_input=pre_input, + ) + 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": expected_error} + + +async def test_manual_step_retains_user_input_on_error(hass: HomeAssistant) -> None: + """Test the manual form keeps the user's entered values after a validation error. + + Previously, any error re-render fell back to the (possibly empty) + found_device defaults, discarding everything the user had just typed. + """ + 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"}, + ) + + submitted = {**EXTENDED_DATA, CONF_TOKEN: "zz"} + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input=submitted, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manually" + assert result["errors"] == {"base": "invalid_token"} + data_schema = result["data_schema"].schema + assert ( + get_schema_suggested_value(data_schema, CONF_DEVICE_ID) + == (submitted[CONF_DEVICE_ID]) + ) + assert ( + get_schema_suggested_value(data_schema, CONF_IP_ADDRESS) + == (submitted[CONF_IP_ADDRESS]) + ) + assert get_schema_suggested_value(data_schema, CONF_TOKEN) == "zz" + + +async def test_manual_step_retries_discovery_after_mismatch( + hass: HomeAssistant, +) -> None: + """Test resubmitting corrected data triggers a fresh discovery. + + Previously, once self.devices held a stale entry from a failed attempt, + any later submission would fail forever with "invalid_device_id" without + ever retrying discover(), even if the resubmitted data was correct. + """ + 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"}, + ) + + dm = MagicMock() + dm.connect.return_value = True + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.discover", + side_effect=[ + {TEST_DEVICE_ID + 1: {**BASE_DATA, CONF_TYPE: TEST_TYPE}}, + DISCOVERY_RESULT, + ], + ) as mock_discover, + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + return_value=dm, + ), + ): + # first attempt: discovery finds a different device than requested + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={**EXTENDED_DATA}, + ) + assert result["errors"] == {"base": "invalid_device_id_for_ip"} + + # resubmitting the same (now correct) data must retry discovery + # rather than dead-ending on the stale result from the first attempt + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={**EXTENDED_DATA}, + ) + + assert mock_discover.call_count == 2 + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_search_flow_no_new_devices_found(hass: HomeAssistant) -> None: + """Test the search step reports no_devices when discovery only finds already-configured devices.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE_ID: TEST_DEVICE_ID, CONF_IP_ADDRESS: TEST_IP_ADDRESS}, + ) + entry.add_to_hass(hass) + + 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": "search"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "search" + + with patch( + "homeassistant.components.midea_lan.config_flow.discover", + return_value=DISCOVERY_RESULT, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_IP_ADDRESS: TEST_IP_ADDRESS}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "search" + assert result["errors"] == {"base": "no_devices"} + + +async def test_auto_flow_cloud_device_info_overrides_name_and_subtype( + hass: HomeAssistant, +) -> None: + """Test cloud device_info overrides the entry title and subtype on creation.""" + 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_lan.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={"name": "Cloud Device Name", "model_number": 3} + ) + cloud.get_cloud_keys = AsyncMock( + return_value={"method": {"token": TEST_TOKEN, "key": TEST_KEY}} + ) + + dm = MagicMock() + dm.connect.return_value = True + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_default_keys", + AsyncMock(return_value={}), + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + 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["title"] == "Cloud Device Name" + assert result["data"][CONF_SUBTYPE] == 3 + assert result["data"][CONF_TOKEN] == TEST_TOKEN + assert result["data"][CONF_KEY] == TEST_KEY + + +async def test_auto_flow_v3_preset_phase1_cloud_keys_success( + hass: HomeAssistant, +) -> None: + """Test phase 1 cloud keys succeed immediately after a preset login.""" + 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_lan.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( + return_value={"method": {"token": TEST_TOKEN, "key": TEST_KEY}} + ) + + dm = MagicMock() + dm.connect.return_value = True + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_default_keys", + AsyncMock(return_value={}), + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + 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_DEVICE_ID] == TEST_DEVICE_ID + assert result["data"][CONF_TOKEN] == TEST_TOKEN + assert result["data"][CONF_KEY] == TEST_KEY + + +async def test_auto_flow_v3_preset_phase1_default_key_success( + hass: HomeAssistant, +) -> None: + """Test phase 1 falls back to the well-known default key when cloud keys are empty.""" + 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_lan.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(return_value={}) + + dm = MagicMock() + dm.connect.return_value = True + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_default_keys", + AsyncMock(return_value={"builtin": {"token": TEST_TOKEN, "key": TEST_KEY}}), + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + 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_DEVICE_ID] == TEST_DEVICE_ID + assert result["data"][CONF_TOKEN] == TEST_TOKEN + assert result["data"][CONF_KEY] == TEST_KEY + + +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}} + + 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_lan.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=[ + { + "keyA": {"token": TEST_TOKEN, "key": TEST_KEY}, + "keyB": {"token": TEST_TOKEN, "key": TEST_KEY}, + }, + {"keyC": {"token": TEST_TOKEN, "key": TEST_KEY}}, + ] + ) + + dm = MagicMock() + dm.connect.side_effect = [False, False, False, False] + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_default_keys", + AsyncMock(return_value={"builtin": {"token": TEST_TOKEN, "key": TEST_KEY}}), + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + 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.FORM + assert result["step_id"] == "auto" + assert result["errors"] == {"base": "token_unavailable"} + assert dm.connect.call_count == 4 + + +async def test_auto_flow_v3_phase2_login_failed(hass: HomeAssistant) -> None: + """Test phase 2's forced preset re-login failing surfaces 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_lan.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(side_effect=[True, False]) + cloud.get_device_info = AsyncMock(return_value=None) + cloud.get_cloud_keys = AsyncMock(return_value={}) + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.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": "preset_login_failed"} + assert cloud.login.call_count == 2 + + +async def test_auto_flow_v3_phase2_no_keys_available(hass: HomeAssistant) -> None: + """Test phase 2 succeeding to log in but still finding no keys surfaces token_unavailable.""" + 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_lan.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(side_effect=[True, True]) + cloud.get_device_info = AsyncMock(return_value=None) + cloud.get_cloud_keys = AsyncMock(return_value={}) + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.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": "token_unavailable"} + assert cloud.get_cloud_keys.call_count == 2 + + +async def test_auto_flow_v3_phase2_success_after_phase1_failure( + hass: HomeAssistant, +) -> None: + """Test the two-phase fallback: phase 1 finds no usable key, phase 2's forced preset re-login yields a working key. + + Phase 1 (the key lookup using the already-authenticated cloud) must + return no usable token/key here, so that only phase 2 (re-login with + the preset account, then a second key lookup) can produce the + successful entry. The call-count assertions ensure the phase 2 path + actually executed rather than short-circuiting on phase 1. + """ + 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_lan.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(side_effect=[True, True]) + cloud.get_device_info = AsyncMock(return_value=None) + cloud.get_cloud_keys = AsyncMock( + side_effect=[ + {}, + {"method": {"token": TEST_TOKEN, "key": TEST_KEY}}, + ] + ) + + dm = MagicMock() + dm.connect.return_value = True + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_default_keys", + AsyncMock(return_value={}), + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + 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_DEVICE_ID] == TEST_DEVICE_ID + assert result["data"][CONF_TOKEN] == TEST_TOKEN + assert result["data"][CONF_KEY] == TEST_KEY + assert cloud.login.call_count == 2 + assert cloud.get_cloud_keys.call_count == 2 + + +async def test_auto_flow_recovers_after_preset_login_error( + hass: HomeAssistant, +) -> None: + """Test the auto flow returns to auth_method after a login/key failure. + + Previously, a failed preset-login retry inside async_step_auto left + self._login_data and self.cloud populated with the broken credentials, + so re-selecting the device would skip auth_method entirely and keep + retrying with the same stale login, failing forever. + """ + 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_lan.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(side_effect=[True, False]) + cloud.get_device_info = AsyncMock(return_value=None) + cloud.get_cloud_keys = AsyncMock(return_value={}) + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.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["step_id"] == "auto" + assert result["errors"] == {"base": "preset_login_failed"} + + # re-selecting the device must route back through auth_method + # instead of silently retrying with the stale login state + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_DEVICE: TEST_DEVICE_ID}, + ) + + assert result["step_id"] == "auth_method" + + +@pytest.mark.parametrize( + "protocol", + [ + pytest.param(ProtocolVersion.V1, id="v1"), + pytest.param(ProtocolVersion.V2, id="v2"), + ], +) +async def test_auto_flow_v1_v2_success_when_cloud_down( + hass: HomeAssistant, + protocol: ProtocolVersion, +) -> None: + """Test v1/v2 devices are added without ever using the cloud, even if it is down.""" + mock_devices = { + TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE, CONF_PROTOCOL: protocol}, + } + + 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": "search"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "search" + + dm = MagicMock() + dm.connect.return_value = True + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.discover", + return_value=mock_devices, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + return_value=dm, + ), + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + side_effect=AssertionError("cloud must not be used"), + ), + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_IP_ADDRESS: "auto"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auto" + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_DEVICE: TEST_DEVICE_ID}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_DEVICE_ID] == TEST_DEVICE_ID + assert result["data"][CONF_PROTOCOL] == protocol + + +async def test_login_credentials_step_renders_with_cloud_servers( + hass: HomeAssistant, +) -> None: + """Test login_credentials step renders form regardless of cloud server list shape.""" + 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": "search"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "search" + + with patch( + "homeassistant.components.midea_lan.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["type"] is FlowResultType.FORM + assert result["step_id"] == "auto" + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_DEVICE: TEST_DEVICE_ID}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth_method" + + with patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_cloud_servers", + AsyncMock(return_value={1: "CN", 2: DEFAULT_CLOUD}), + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={"login_mode": LOGIN_MODE_ACCOUNT}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "login_credentials" + + +async def test_login_credentials_step_login_failed_sets_error( + hass: HomeAssistant, +) -> None: + """Test login_credentials failures stay on the step with proper error state.""" + 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": "search"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "search" + + with patch( + "homeassistant.components.midea_lan.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["type"] is FlowResultType.FORM + assert result["step_id"] == "auto" + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_DEVICE: TEST_DEVICE_ID}, + ) + assert result["type"] is FlowResultType.FORM + 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["type"] is FlowResultType.FORM + assert result["step_id"] == "login_credentials" + + cloud = MagicMock() + cloud.login = AsyncMock(return_value=False) + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_cloud_servers", + AsyncMock(return_value={1: DEFAULT_CLOUD}), + ), + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.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": "login_failed"} + + data_schema = result["data_schema"].schema + assert get_schema_suggested_value(data_schema, CONF_ACCOUNT) == "user" + assert get_schema_suggested_value(data_schema, CONF_SERVER) == DEFAULT_CLOUD + + +async def test_login_credentials_step_recovers_after_failed_login( + hass: HomeAssistant, +) -> None: + """Test the user can correct a failed login and complete the flow. + + This is the config-flow-test-coverage error-recovery scenario: the flow + hits an error (a wrong password), the user resubmits corrected data on + the same form, and the flow proceeds all the way to CREATE_ENTRY. + """ + discovered_device = { + TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE}, + } + + 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": "search"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "search" + + with patch( + "homeassistant.components.midea_lan.config_flow.discover", + return_value=discovered_device, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_IP_ADDRESS: "auto"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auto" + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_DEVICE: TEST_DEVICE_ID}, + ) + assert result["type"] is FlowResultType.FORM + 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["type"] is FlowResultType.FORM + assert result["step_id"] == "login_credentials" + + cloud = MagicMock() + cloud.login = AsyncMock(side_effect=[False, True]) + cloud.get_device_info = AsyncMock(return_value=None) + cloud.get_cloud_keys = AsyncMock( + return_value={"method": {"token": TEST_TOKEN, "key": TEST_KEY}} + ) + + dm = MagicMock() + dm.connect.return_value = True + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_default_keys", + AsyncMock(return_value={}), + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + return_value=dm, + ), + ): + # first attempt: wrong password + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_SERVER: DEFAULT_CLOUD, + CONF_ACCOUNT: "user", + CONF_PASSWORD: "wrong-pass", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "login_credentials" + assert result["errors"] == {"base": "login_failed"} + + # user corrects the password and resubmits; the flow must complete + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_SERVER: DEFAULT_CLOUD, + CONF_ACCOUNT: "user", + CONF_PASSWORD: "correct-pass", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_DEVICE_ID] == TEST_DEVICE_ID + assert result["data"][CONF_TOKEN] == TEST_TOKEN + assert result["data"][CONF_KEY] == TEST_KEY + + +@pytest.mark.parametrize( + ("all_devices", "expected_table_fragment"), + [ + pytest.param({}, "Not found", id="empty"), + pytest.param( + { + TEST_DEVICE_ID: { + CONF_TYPE: TEST_TYPE, + CONF_IP_ADDRESS: TEST_IP_ADDRESS, + "sn": "abc", + } + }, + "YES", + id="single_device", + ), + ], +) +async def test_list_step_shows_discovery_table( + hass: HomeAssistant, + all_devices: dict[int, dict[str, object]], + expected_table_fragment: str, +) -> None: + """Test list step shows either table output or not-found message.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + flow_id = result["flow_id"] + + with patch( + "homeassistant.components.midea_lan.config_flow.discover", + return_value=all_devices, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={"next_step_id": "list"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "list" + assert expected_table_fragment in result["description_placeholders"]["table"] + + +async def test_list_step_submit_returns_to_user_menu(hass: HomeAssistant) -> None: + """Test submitting list step returns user menu.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + flow_id = result["flow_id"] + + with patch( + "homeassistant.components.midea_lan.config_flow.discover", + return_value={}, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={"next_step_id": "list"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "list" + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={"ok": True}, + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + +async def test_manual_step_v3_missing_token_key_sets_retrieved_values( + hass: HomeAssistant, +) -> None: + """Test manual step writes cloud-provided token/key into user_input.""" + 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: "", + } + dm = MagicMock() + # First connect call is the cloud key candidate check (must succeed to + # select the key); second is the final entry creation attempt, which + # must fail to exercise the device_auth_failed branch. + dm.connect.side_effect = [True, False] + + 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_lan.config_flow.discover", + return_value={TEST_DEVICE_ID: device}, + ), + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_default_keys", + AsyncMock(return_value={}), + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + ) as mock_midea_device, + ): + mock_midea_device.return_value = dm + 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": "device_auth_failed"} + + assert mock_midea_device.call_args.kwargs["token"] == TEST_TOKEN + assert mock_midea_device.call_args.kwargs["key"] == TEST_KEY + + +async def test_manually_flow_success(hass: HomeAssistant) -> None: + """Test the full manual configuration flow through to entry creation.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "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_lan.config_flow.discover", + return_value=DISCOVERY_RESULT, + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + ) as mock_midea_device, + ): + mock_device = MagicMock() + mock_device.connect.return_value = True + mock_midea_device.return_value = mock_device + + 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 + assert result["title"] == MIDEA_DEVICE_NAMES[TEST_TYPE] + assert result["data"][CONF_DEVICE_ID] == TEST_DEVICE_ID + assert result["data"][CONF_IP_ADDRESS] == TEST_IP_ADDRESS + assert result["data"][CONF_TOKEN] == TEST_TOKEN + assert result["data"][CONF_KEY] == TEST_KEY + + +async def test_login_credentials_step_falls_back_to_default_cloud( + hass: HomeAssistant, +) -> None: + """Test login_credentials step falls back to DEFAULT_CLOUD with no cloud servers.""" + 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": "search"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "search" + + with patch( + "homeassistant.components.midea_lan.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["type"] is FlowResultType.FORM + assert result["step_id"] == "auto" + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_DEVICE: TEST_DEVICE_ID}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth_method" + + with patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_cloud_servers", + AsyncMock(return_value={}), + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={"login_mode": LOGIN_MODE_ACCOUNT}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "login_credentials" + + +async def test_login_credentials_step_success_resumes_auto_flow( + hass: HomeAssistant, +) -> None: + """Test login_credentials step stores login data and resumes device processing.""" + 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": "search"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "search" + + discovered_device = { + TEST_DEVICE_ID: {**BASE_DATA, CONF_TYPE: TEST_TYPE}, + } + with patch( + "homeassistant.components.midea_lan.config_flow.discover", + return_value=discovered_device, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_IP_ADDRESS: "auto"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auto" + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_DEVICE: TEST_DEVICE_ID}, + ) + assert result["type"] is FlowResultType.FORM + 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["type"] is FlowResultType.FORM + assert result["step_id"] == "login_credentials" + + cloud = MagicMock() + cloud.login = AsyncMock(return_value=True) + cloud.get_device_info = AsyncMock(return_value=None) + cloud.get_cloud_keys = AsyncMock( + return_value={"method": {"token": TEST_TOKEN, "key": TEST_KEY}} + ) + + dm = MagicMock() + dm.connect.return_value = True + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ) as mock_session, + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ) as mock_get_midea_cloud, + patch( + "homeassistant.components.midea_lan.config_flow.MideaCloud.get_default_keys", + AsyncMock(return_value={}), + ), + patch( + "homeassistant.components.midea_lan.config_flow.MideaDevice", + return_value=dm, + ), + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={ + CONF_SERVER: DEFAULT_CLOUD, + CONF_ACCOUNT: "user", + CONF_PASSWORD: "pass", + }, + ) + + mock_get_midea_cloud.assert_called_once_with( + DEFAULT_CLOUD, mock_session.return_value, "user", "pass" + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_DEVICE_ID] == TEST_DEVICE_ID + + +async def test_auth_method_account_mode_redirects_to_login_credentials( + hass: HomeAssistant, +) -> None: + """Test auth_method step routes to login_credentials when account mode is chosen.""" + 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": "search"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "search" + + with patch( + "homeassistant.components.midea_lan.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["type"] is FlowResultType.FORM + assert result["step_id"] == "auto" + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_DEVICE: TEST_DEVICE_ID}, + ) + assert result["type"] is FlowResultType.FORM + 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["type"] is FlowResultType.FORM + assert result["step_id"] == "login_credentials" + + +async def test_auth_method_preset_login_failed(hass: HomeAssistant) -> None: + """Test auth_method step surfaces preset_login_failed when preset login fails.""" + 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": "search"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "search" + + with patch( + "homeassistant.components.midea_lan.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["type"] is FlowResultType.FORM + assert result["step_id"] == "auto" + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_DEVICE: TEST_DEVICE_ID}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth_method" + + cloud = MagicMock() + cloud.login = AsyncMock(return_value=False) + + with ( + patch( + "homeassistant.components.midea_lan.config_flow.async_get_clientsession", + return_value=object(), + ), + patch( + "homeassistant.components.midea_lan.config_flow.get_midea_cloud", + return_value=cloud, + ), + ): + 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"] == "auth_method" + assert result["errors"] == {"base": "preset_login_failed"} diff --git a/tests/components/midea_lan/test_entity.py b/tests/components/midea_lan/test_entity.py new file mode 100644 index 00000000000000..0c664618ad484c --- /dev/null +++ b/tests/components/midea_lan/test_entity.py @@ -0,0 +1,101 @@ +"""Tests for midea_lan entity behavior via loaded platforms.""" + +from collections.abc import Callable + +from midealocal.devices.ac import DeviceAttributes as ACAttributes +import pytest + +from homeassistant.core import CoreState, HomeAssistant + +from . import setup_integration +from .conftest import DummyDevice, default_ac_device, entity_entries +from .const import TEST_DEVICE_ID + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ( + "update", + "status", + "availability", + "expected_current_temp", + "expected_unavailable", + ), + [ + pytest.param( + {ACAttributes.indoor_temperature: 24.0}, + {"available": True}, + True, + 24.0, + False, + id="temperature_update", + ), + pytest.param( + {}, + {"available": False}, + False, + None, + True, + id="availability_update", + ), + pytest.param( + {ACAttributes.indoor_temperature: 24.0}, + {"power": True, ACAttributes.indoor_temperature: 24.0}, + True, + 24.0, + False, + id="attribute_update_without_available_key", + ), + ], +) +async def test_entity_updates_from_device_callback( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], + update: dict[str, float], + status: dict[str, bool | float], + availability: bool, + expected_current_temp: float | None, + expected_unavailable: bool, +) -> None: + """Test entity callback updates state and availability.""" + device = default_ac_device() + 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"] + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.attributes["current_temperature"] == 21.0 + assert state.state != "unavailable" + + device.attributes.update(update) + device.available = availability + device.notify_update(status) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.attributes.get("current_temperature") == expected_current_temp + assert (state.state == "unavailable") is expected_unavailable + + +async def test_entity_callback_ignored_while_hass_stopping( + hass: HomeAssistant, + mock_config_entry: Callable[[DummyDevice], MockConfigEntry], +) -> None: + """Test update callback does not schedule updates while Home Assistant stops.""" + device = default_ac_device() + 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"] + + assert hass.states.get(entity_entry.entity_id) is not None + + device.attributes[ACAttributes.indoor_temperature] = 25.0 + hass.set_state(CoreState.stopping) + device.notify_update({"available": True}) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_entry.entity_id)) + assert state.attributes["current_temperature"] == 21.0 diff --git a/tests/components/midea_lan/test_init.py b/tests/components/midea_lan/test_init.py new file mode 100644 index 00000000000000..7d75bb7a791730 --- /dev/null +++ b/tests/components/midea_lan/test_init.py @@ -0,0 +1,109 @@ +"""Tests for midea_lan __init__.py.""" + +from unittest.mock import patch + +from midealocal.const import DeviceType, ProtocolVersion + +from homeassistant.components.midea_lan.const import CONF_KEY, CONF_SUBTYPE, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_IP_ADDRESS, + CONF_MODEL, + CONF_NAME, + CONF_PORT, + CONF_PROTOCOL, + CONF_TOKEN, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import DummyDevice +from .const import TEST_DEVICE_ID + +from tests.common import MockConfigEntry + +_ENTRY_DATA = { + CONF_DEVICE_ID: TEST_DEVICE_ID, + CONF_NAME: "m", + CONF_TYPE: DeviceType.AC, + CONF_IP_ADDRESS: "1.1.1.1", + CONF_PORT: 6444, + CONF_MODEL: "m", + CONF_PROTOCOL: ProtocolVersion.V2, + CONF_TOKEN: "", + CONF_KEY: "", + CONF_SUBTYPE: 0, +} + + +async def test_async_setup(hass: HomeAssistant) -> None: + """Test the midea_lan domain can be set up without any config entries.""" + assert await async_setup_component(hass, DOMAIN, {}) + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test async_unload_entry unloads platforms and closes the device.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry.add_to_hass(hass) + device = DummyDevice(DeviceType.AC) + with patch( + "homeassistant.components.midea_lan.device_selector", + return_value=device, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + assert device.daemon is True + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + assert ("close",) in device.calls + + +async def test_async_setup_entry_paths(hass: HomeAssistant) -> None: + """Test async_setup_entry for success and no-device return.""" + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.midea_lan.device_selector", + return_value=DummyDevice(DeviceType.AC), + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + entry2 = MockConfigEntry( + domain=DOMAIN, + data={**_ENTRY_DATA, CONF_DEVICE_ID: TEST_DEVICE_ID + 1}, + ) + entry2.add_to_hass(hass) + with patch( + "homeassistant.components.midea_lan.device_selector", + return_value=None, + ): + await hass.config_entries.async_setup(entry2.entry_id) + assert entry2.state is ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_not_ready_on_connect_failure( + hass: HomeAssistant, +) -> None: + """Test async_setup_entry raises ConfigEntryNotReady when connect returns False. + + The real device.connect() already catches SocketException/AuthException + internally and reports failure by returning False; it never raises them. + It can also leave the socket open in that case (e.g. when authentication + fails), so the socket must be closed explicitly to avoid a ResourceWarning. + """ + entry = MockConfigEntry(domain=DOMAIN, data=_ENTRY_DATA) + entry.add_to_hass(hass) + device = DummyDevice(DeviceType.AC) + with ( + patch( + "homeassistant.components.midea_lan.device_selector", + return_value=device, + ), + patch.object(device, "connect", return_value=False), + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY + assert ("close_socket",) in device.calls