Files
core/homeassistant/components/midea/climate.py
T

714 lines
22 KiB
Python

"""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 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 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",
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 _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)