"""Midea Climate entries.""" from dataclasses import dataclass 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 DeviceAttributes as C3Attributes, MideaC3Device 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 MideaConfigEntry, MideaEntity 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", translation_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: MideaConfigEntry, 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.""" self._protocol_hvac_modes: dict[int, HVACMode] = {} super().__init__(device, 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 mode in self._protocol_hvac_modes: return self._protocol_hvac_modes[mode] return None def _hvac_to_protocol_mode(self, hvac_mode: HVACMode) -> int: """Convert Home Assistant HVAC mode to protocol mode value.""" for key, value in self._protocol_hvac_modes.items(): if value == hvac_mode: return key raise ValueError(f"Unsupported HVAC mode: {hvac_mode}") # pragma: no cover @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: if hvac_mode and hvac_mode not in self.hvac_modes: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="unsupported_hvac_mode", translation_placeholders={"hvac_mode": hvac_mode}, ) self._device.set_raw_target_temperature( target_temperature=temperature, hvac_mode=hvac_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_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._protocol_hvac_modes = { 1: HVACMode.AUTO, 2: HVACMode.COOL, 3: HVACMode.DRY, 4: HVACMode.HEAT, 5: HVACMode.FAN_ONLY, } self._attr_target_temperature_step = float( PRECISION_WHOLE if self._device.temperature_step == 1 else PRECISION_HALVES, ) @property @override def hvac_modes(self) -> list[HVACMode]: """Midea AC Climate hvac modes. The device reports the generic mode names in protocol-index order (``off`` first, ``fan_only`` last), already filtered by its B5 capabilities. """ return [HVACMode(mode) for mode in self._device.raw_hvac_modes] @property @override 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.""" self._device.set_raw_swing_mode(swing_mode) 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] def __init__( self, device: MideaCCDevice, description: MideaClimateEntityDescription ) -> None: """Midea CC Climate entity init.""" super().__init__(device, description) self._protocol_hvac_modes = { 1: HVACMode.FAN_ONLY, 2: HVACMode.DRY, 3: HVACMode.HEAT, 4: HVACMode.COOL, 5: HVACMode.AUTO, } @property @override def fan_modes(self) -> list[str] | None: """Midea CC Climate fan modes.""" return list(self._device.raw_fan_modes) @property @override def fan_mode(self) -> str | None: """Midea CC Climate fan mode.""" return self._device.raw_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_raw_fan_mode(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 def __init__( self, device: MideaCFDevice, description: MideaClimateEntityDescription ) -> None: """Midea CF Climate entity init.""" super().__init__(device, description) self._protocol_hvac_modes = { 1: HVACMode.AUTO, 2: HVACMode.COOL, 3: HVACMode.HEAT, } @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: self._device.set_raw_target_temperature( target_temperature=self.target_temperature or self.min_temp, hvac_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._protocol_hvac_modes = { 1: HVACMode.AUTO, 2: HVACMode.COOL, 3: HVACMode.HEAT, } 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_raw_hvac_mode(hvac_mode, zone=self._zone) 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)