755 lines
28 KiB
Python
755 lines
28 KiB
Python
"""Implement the Yandex Smart Home mode capabilities."""
|
||
|
||
from abc import ABC, abstractmethod
|
||
from contextlib import suppress
|
||
from enum import StrEnum
|
||
import logging
|
||
import math
|
||
from typing import Any, Iterable, Protocol
|
||
|
||
from homeassistant.components import climate, fan, humidifier, media_player, vacuum
|
||
from homeassistant.components.climate import ClimateEntityFeature, HVACMode
|
||
from homeassistant.components.fan import FanEntityFeature
|
||
from homeassistant.components.humidifier import HumidifierEntityFeature
|
||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||
from homeassistant.components.vacuum import VacuumEntityFeature
|
||
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||
from homeassistant.core import Context
|
||
from homeassistant.util.percentage import ordered_list_item_to_percentage, percentage_to_ordered_list_item
|
||
|
||
from .capability import STATE_CAPABILITIES_REGISTRY, Capability, StateCapability
|
||
from .const import CONF_ENTITY_MODE_MAP, CONF_FEATURES, STATE_NONE, MediaPlayerFeature
|
||
from .helpers import APIError
|
||
from .schema import (
|
||
CapabilityType,
|
||
ModeCapabilityInstance,
|
||
ModeCapabilityInstanceActionState,
|
||
ModeCapabilityMode,
|
||
ModeCapabilityParameters,
|
||
ResponseCode,
|
||
)
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
|
||
class GenericMode(StrEnum):
|
||
GENTLE = "gentle" # tuya vacuum?
|
||
MAX_PLUS_SIGN = "max+" # deebot?
|
||
HIGHEST = "highest" # smartir
|
||
|
||
|
||
class SmartThinQFanMode(StrEnum):
|
||
LOW_MID = "low_mid"
|
||
MID_HIGH = "mid_high"
|
||
|
||
|
||
class RoborockCleanupMode(StrEnum):
|
||
OFF = "off"
|
||
SILENT = "silent"
|
||
BALANCED = "balanced"
|
||
TURBO = "turbo"
|
||
MAX = "max"
|
||
MAX_PLUS = "max_plus"
|
||
CUSTOM = "custom"
|
||
|
||
|
||
class RoombaCleanupMode(StrEnum):
|
||
AUTOMATIC = "Automatic"
|
||
ECO = "Eco"
|
||
PERFORMANCE = "Performance"
|
||
STANDARD = "Standard"
|
||
|
||
|
||
class TionFanSpeed(StrEnum):
|
||
S1 = "1"
|
||
S2 = "2"
|
||
S3 = "3"
|
||
S4 = "4"
|
||
S5 = "5"
|
||
S6 = "6"
|
||
|
||
|
||
class XiaomiHumidifierMode(StrEnum):
|
||
MID = "mid"
|
||
|
||
|
||
class XiaomiMiotHumidifierMode(StrEnum):
|
||
CONST_HUMIDITY = "Const Humidity" # leshow.humidifier.jsq1
|
||
|
||
|
||
class XiaomiFanMode(StrEnum):
|
||
AUTO = "Auto"
|
||
SILENT = "Silent"
|
||
LOW = "Low"
|
||
FAVORITE = "Favorite"
|
||
IDLE = "Idle"
|
||
MEDIUM = "Medium"
|
||
MIDDLE = "Middle"
|
||
HIGH = "High"
|
||
STRONG = "Strong"
|
||
FAN = "Fan"
|
||
NATURE = "Nature"
|
||
|
||
|
||
class XiaomiMiotFanMode(StrEnum):
|
||
LEVEL_1 = "Level 1"
|
||
LEVEL_2 = "Level 2"
|
||
LEVEL_3 = "Level 3"
|
||
LEVEL_4 = "Level 4"
|
||
LEVEL_5 = "Level 5"
|
||
|
||
|
||
class XiaomiMiotCleanupMode(StrEnum):
|
||
SILENT = "Silent"
|
||
SLIENT = "slient" # https://github.com/al-one/hass-xiaomi-miot/issues/1605
|
||
BASIC = "Basic"
|
||
STRONG = "Strong"
|
||
FULL_SPEED = "Full Speed"
|
||
MOP_ONLY = "Mop Only"
|
||
CUSTOM = "Custom"
|
||
|
||
|
||
class ModeCapability(Capability[ModeCapabilityInstanceActionState], Protocol):
|
||
"""Base class for capabilities with mode functionality like thermostat mode or fan speed."""
|
||
|
||
type: CapabilityType = CapabilityType.MODE
|
||
instance: ModeCapabilityInstance
|
||
|
||
_modes_map_default: dict[ModeCapabilityMode, list[str]] = {}
|
||
_modes_map_index_fallback: dict[int, ModeCapabilityMode] = {
|
||
0: ModeCapabilityMode.ONE,
|
||
1: ModeCapabilityMode.TWO,
|
||
2: ModeCapabilityMode.THREE,
|
||
3: ModeCapabilityMode.FOUR,
|
||
4: ModeCapabilityMode.FIVE,
|
||
5: ModeCapabilityMode.SIX,
|
||
6: ModeCapabilityMode.SEVEN,
|
||
7: ModeCapabilityMode.EIGHT,
|
||
8: ModeCapabilityMode.NINE,
|
||
9: ModeCapabilityMode.TEN,
|
||
}
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
"""Test if the capability is supported."""
|
||
return bool(self.supported_yandex_modes)
|
||
|
||
@property
|
||
def parameters(self) -> ModeCapabilityParameters:
|
||
"""Return parameters for a devices list request."""
|
||
return ModeCapabilityParameters.from_list(self.instance, self.supported_yandex_modes)
|
||
|
||
@property
|
||
def supported_yandex_modes(self) -> list[ModeCapabilityMode]:
|
||
"""Returns a list of supported Yandex modes."""
|
||
modes = set()
|
||
for ha_value in self.supported_ha_modes:
|
||
if value := self.get_yandex_mode_by_ha_mode(ha_value, hide_warnings=True):
|
||
modes.add(value)
|
||
return sorted(modes)
|
||
|
||
@property
|
||
def supported_ha_modes(self) -> list[str]:
|
||
"""Returns list of supported HA modes."""
|
||
return [str(m) for m in self._ha_modes]
|
||
|
||
@property
|
||
def modes_map(self) -> dict[ModeCapabilityMode, list[str]]:
|
||
"""Return a modes mapping between Yandex and HA."""
|
||
return self.modes_map_config or self._modes_map_default
|
||
|
||
@property
|
||
def modes_map_config(self) -> dict[ModeCapabilityMode, list[str]]:
|
||
"""Return a modes mapping from a entity configuration."""
|
||
if CONF_ENTITY_MODE_MAP in self._entity_config:
|
||
return {
|
||
ModeCapabilityMode(k): v
|
||
for k, v in self._entity_config[CONF_ENTITY_MODE_MAP].get(self.instance, {}).items()
|
||
}
|
||
return {}
|
||
|
||
def get_yandex_mode_by_ha_mode(self, ha_mode: str, hide_warnings: bool = False) -> ModeCapabilityMode | None:
|
||
"""Return Yandex mode for HA mode (case-insensitive)."""
|
||
ha_mode_lower = ha_mode.lower()
|
||
mode = None
|
||
|
||
for yandex_mode, names in self.modes_map.items():
|
||
if ha_mode_lower in [n.lower() for n in names]:
|
||
mode = yandex_mode
|
||
break
|
||
|
||
supported_lower = [m.lower() for m in self.supported_ha_modes]
|
||
if mode is not None and ha_mode_lower not in supported_lower:
|
||
_LOGGER.debug(
|
||
f"HA mode '{ha_mode}' mapped but case-mismatch in supported_ha_modes: {self.supported_ha_modes}"
|
||
)
|
||
# НЕ бросаем ошибку — продолжаем работу
|
||
|
||
if not self.modes_map_config:
|
||
if mode is None:
|
||
with suppress(ValueError):
|
||
mode = ModeCapabilityMode(ha_mode_lower)
|
||
|
||
if mode is None and ha_mode_lower != STATE_OFF:
|
||
try:
|
||
idx = next(
|
||
(i for i, m in enumerate(supported_lower) if m == ha_mode_lower), None
|
||
)
|
||
if idx is not None:
|
||
mode = self._modes_map_index_fallback.get(idx)
|
||
except (IndexError, ValueError, KeyError):
|
||
pass
|
||
|
||
if mode is None and not hide_warnings:
|
||
if ha_mode_lower not in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE):
|
||
if ha_mode_lower in supported_lower:
|
||
_LOGGER.warning(
|
||
f"Failed to get Yandex mode for mode '{ha_mode}' for {self}. "
|
||
f"It may cause inconsistencies between Yandex and HA. "
|
||
f"See https://docs.yaha-cloud.ru/v1.0.x/config/modes/"
|
||
)
|
||
|
||
return mode
|
||
|
||
def get_ha_mode_by_yandex_mode(self, yandex_mode: ModeCapabilityMode) -> str:
|
||
"""Return HA mode for Yandex mode."""
|
||
ha_modes = self.modes_map.get(yandex_mode, [])
|
||
if not self.modes_map_config:
|
||
ha_modes.append(yandex_mode.value)
|
||
|
||
for ha_mode in ha_modes:
|
||
for am in self.supported_ha_modes:
|
||
if am.lower() == ha_mode.lower():
|
||
return am
|
||
|
||
if not self.modes_map_config:
|
||
for ha_idx, yandex_mode_idx in self._modes_map_index_fallback.items():
|
||
if yandex_mode_idx == yandex_mode:
|
||
return self.supported_ha_modes[ha_idx]
|
||
|
||
raise APIError(
|
||
ResponseCode.INVALID_VALUE,
|
||
f"Unsupported mode '{yandex_mode}' for {self}, see https://docs.yaha-cloud.ru/v1.0.x/config/modes/",
|
||
)
|
||
|
||
@abstractmethod
|
||
def get_value(self) -> ModeCapabilityMode | None:
|
||
"""Return the current capability value."""
|
||
...
|
||
|
||
@property
|
||
@abstractmethod
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
"""Returns list of HA modes."""
|
||
...
|
||
|
||
|
||
class StateModeCapability(ModeCapability, StateCapability[ModeCapabilityInstanceActionState], Protocol):
|
||
"""Base class for a mode capability based on the state."""
|
||
|
||
def get_value(self) -> ModeCapabilityMode | None:
|
||
"""Return the current capability value."""
|
||
if self._ha_value is None:
|
||
return None
|
||
return self.get_yandex_mode_by_ha_mode(str(self._ha_value), False)
|
||
|
||
@property
|
||
def _ha_value(self) -> Any:
|
||
"""Return the current unmapped capability value."""
|
||
return self.state.state
|
||
|
||
|
||
class ThermostatCapability(StateModeCapability):
|
||
"""Capability to control mode of a climate device."""
|
||
|
||
instance = ModeCapabilityInstance.THERMOSTAT
|
||
_modes_map_default = {
|
||
ModeCapabilityMode.HEAT: [HVACMode.HEAT],
|
||
ModeCapabilityMode.COOL: [HVACMode.COOL],
|
||
ModeCapabilityMode.AUTO: [HVACMode.HEAT_COOL, HVACMode.AUTO],
|
||
ModeCapabilityMode.DRY: [HVACMode.DRY],
|
||
ModeCapabilityMode.FAN_ONLY: [HVACMode.FAN_ONLY],
|
||
}
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
"""Test if the capability is supported."""
|
||
if self.state.domain == climate.DOMAIN:
|
||
return super().supported
|
||
return False
|
||
|
||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||
"""Change the capability state."""
|
||
await self._hass.services.async_call(
|
||
climate.DOMAIN,
|
||
climate.SERVICE_SET_HVAC_MODE,
|
||
{
|
||
ATTR_ENTITY_ID: self.state.entity_id,
|
||
climate.ATTR_HVAC_MODE: self.get_ha_mode_by_yandex_mode(state.value),
|
||
},
|
||
blocking=self._wait_for_service_call,
|
||
context=context,
|
||
)
|
||
|
||
@property
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
"""Returns list of HA modes."""
|
||
return self.state.attributes.get(climate.ATTR_HVAC_MODES, []) or []
|
||
|
||
|
||
class SwingCapability(StateModeCapability):
|
||
"""Capability to control swing mode of a climate device."""
|
||
|
||
instance = ModeCapabilityInstance.SWING
|
||
_modes_map_default = {
|
||
ModeCapabilityMode.VERTICAL: ["ud"],
|
||
ModeCapabilityMode.HORIZONTAL: ["lr"],
|
||
ModeCapabilityMode.STATIONARY: [climate.SWING_OFF],
|
||
ModeCapabilityMode.AUTO: [climate.SWING_BOTH, "all"],
|
||
}
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
if self.state.domain == climate.DOMAIN and self._state_features & ClimateEntityFeature.SWING_MODE:
|
||
return super().supported
|
||
return False
|
||
|
||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||
await self._hass.services.async_call(
|
||
climate.DOMAIN,
|
||
climate.SERVICE_SET_SWING_MODE,
|
||
{
|
||
ATTR_ENTITY_ID: self.state.entity_id,
|
||
climate.ATTR_SWING_MODE: self.get_ha_mode_by_yandex_mode(state.value),
|
||
},
|
||
blocking=self._wait_for_service_call,
|
||
context=context,
|
||
)
|
||
|
||
@property
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
return self.state.attributes.get(climate.ATTR_SWING_MODES, []) or []
|
||
|
||
@property
|
||
def _ha_value(self) -> Any:
|
||
return self.state.attributes.get(climate.ATTR_SWING_MODE)
|
||
|
||
|
||
class ProgramCapability(StateModeCapability, ABC):
|
||
"""Base capability to control a device program."""
|
||
|
||
instance = ModeCapabilityInstance.PROGRAM
|
||
|
||
|
||
class ProgramCapabilityClimate(ProgramCapability):
|
||
"""Capability to control the mode preset of a climate device."""
|
||
|
||
_modes_map_default = {
|
||
ModeCapabilityMode.AUTO: [climate.const.PRESET_NONE],
|
||
ModeCapabilityMode.ECO: [climate.const.PRESET_ECO],
|
||
ModeCapabilityMode.MIN: [climate.const.PRESET_AWAY],
|
||
ModeCapabilityMode.TURBO: [climate.const.PRESET_BOOST],
|
||
ModeCapabilityMode.MEDIUM: [climate.const.PRESET_COMFORT],
|
||
ModeCapabilityMode.MAX: [climate.const.PRESET_HOME],
|
||
ModeCapabilityMode.QUIET: [climate.const.PRESET_SLEEP],
|
||
}
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
if self.state.domain == climate.DOMAIN and self._state_features & ClimateEntityFeature.PRESET_MODE:
|
||
return super().supported
|
||
return False
|
||
|
||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||
await self._hass.services.async_call(
|
||
climate.DOMAIN,
|
||
climate.SERVICE_SET_PRESET_MODE,
|
||
{
|
||
ATTR_ENTITY_ID: self.state.entity_id,
|
||
climate.ATTR_PRESET_MODE: self.get_ha_mode_by_yandex_mode(state.value),
|
||
},
|
||
blocking=self._wait_for_service_call,
|
||
context=context,
|
||
)
|
||
|
||
@property
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
return self.state.attributes.get(climate.ATTR_PRESET_MODES, []) or []
|
||
|
||
@property
|
||
def _ha_value(self) -> Any:
|
||
return self.state.attributes.get(climate.ATTR_PRESET_MODE)
|
||
|
||
|
||
class ProgramCapabilityHumidifier(ProgramCapability):
|
||
"""Capability to control the mode of a humidifier device."""
|
||
|
||
_modes_map_default = {
|
||
ModeCapabilityMode.FAN_ONLY: [XiaomiFanMode.FAN],
|
||
ModeCapabilityMode.AUTO: [humidifier.const.MODE_AUTO, XiaomiMiotHumidifierMode.CONST_HUMIDITY],
|
||
ModeCapabilityMode.ECO: [humidifier.const.MODE_ECO, XiaomiFanMode.IDLE],
|
||
ModeCapabilityMode.QUIET: [humidifier.const.MODE_SLEEP, XiaomiFanMode.SILENT],
|
||
ModeCapabilityMode.MIN: [humidifier.const.MODE_AWAY],
|
||
ModeCapabilityMode.MEDIUM: [humidifier.const.MODE_COMFORT, XiaomiFanMode.MIDDLE, XiaomiHumidifierMode.MID],
|
||
ModeCapabilityMode.NORMAL: [humidifier.const.MODE_NORMAL, XiaomiFanMode.FAVORITE],
|
||
ModeCapabilityMode.MAX: [humidifier.const.MODE_HOME],
|
||
ModeCapabilityMode.HIGH: [humidifier.const.MODE_BABY],
|
||
ModeCapabilityMode.TURBO: [humidifier.const.MODE_BOOST, XiaomiFanMode.STRONG],
|
||
}
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
if self.state.domain == humidifier.DOMAIN and self._state_features & HumidifierEntityFeature.MODES:
|
||
return super().supported
|
||
return False
|
||
|
||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||
await self._hass.services.async_call(
|
||
humidifier.DOMAIN,
|
||
humidifier.SERVICE_SET_MODE,
|
||
{
|
||
ATTR_ENTITY_ID: self.state.entity_id,
|
||
humidifier.ATTR_MODE: self.get_ha_mode_by_yandex_mode(state.value),
|
||
},
|
||
blocking=self._wait_for_service_call,
|
||
context=context,
|
||
)
|
||
|
||
@property
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
return self.state.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []) or []
|
||
|
||
@property
|
||
def _ha_value(self) -> Any:
|
||
return self.state.attributes.get(humidifier.ATTR_MODE)
|
||
|
||
|
||
class ProgramCapabilityFan(ProgramCapability):
|
||
"""Capability to control the mode preset of a fan device."""
|
||
|
||
_modes_map_default = {
|
||
ModeCapabilityMode.ECO: [XiaomiFanMode.IDLE],
|
||
ModeCapabilityMode.QUIET: [XiaomiFanMode.SILENT, XiaomiFanMode.NATURE, XiaomiMiotFanMode.LEVEL_1],
|
||
ModeCapabilityMode.LOW: [XiaomiMiotFanMode.LEVEL_2],
|
||
ModeCapabilityMode.MEDIUM: [XiaomiHumidifierMode.MID, XiaomiMiotFanMode.LEVEL_3],
|
||
ModeCapabilityMode.NORMAL: [XiaomiFanMode.FAVORITE],
|
||
ModeCapabilityMode.HIGH: [XiaomiMiotFanMode.LEVEL_4],
|
||
ModeCapabilityMode.TURBO: [XiaomiFanMode.STRONG, XiaomiMiotFanMode.LEVEL_5],
|
||
}
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
if self.state.domain == fan.DOMAIN:
|
||
if self._state_features & FanEntityFeature.PRESET_MODE:
|
||
if (
|
||
self._state_features & FanEntityFeature.SET_SPEED
|
||
and fan.ATTR_PERCENTAGE_STEP in self.state.attributes
|
||
):
|
||
return False
|
||
return super().supported
|
||
return False
|
||
|
||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||
await self._hass.services.async_call(
|
||
fan.DOMAIN,
|
||
fan.SERVICE_SET_PRESET_MODE,
|
||
{
|
||
ATTR_ENTITY_ID: self.state.entity_id,
|
||
fan.ATTR_PRESET_MODE: self.get_ha_mode_by_yandex_mode(state.value),
|
||
},
|
||
blocking=self._wait_for_service_call,
|
||
context=context,
|
||
)
|
||
|
||
@property
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
return self.state.attributes.get(fan.ATTR_PRESET_MODES, []) or []
|
||
|
||
@property
|
||
def _ha_value(self) -> Any:
|
||
return self.state.attributes.get(fan.ATTR_PRESET_MODE)
|
||
|
||
|
||
class InputSourceCapability(StateModeCapability):
|
||
"""Capability to control the input source of a media player device."""
|
||
|
||
instance = ModeCapabilityInstance.INPUT_SOURCE
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
if self.state.domain == media_player.DOMAIN:
|
||
if MediaPlayerFeature.SELECT_SOURCE in self._entity_config.get(CONF_FEATURES, []):
|
||
return super().supported
|
||
if self._state_features & MediaPlayerEntityFeature.SELECT_SOURCE:
|
||
return super().supported
|
||
return False
|
||
|
||
def get_yandex_mode_by_ha_mode(self, ha_mode: str, hide_warnings: bool = False) -> ModeCapabilityMode | None:
|
||
return super().get_yandex_mode_by_ha_mode(ha_mode, hide_warnings=True)
|
||
|
||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||
await self._hass.services.async_call(
|
||
media_player.DOMAIN,
|
||
media_player.SERVICE_SELECT_SOURCE,
|
||
{
|
||
ATTR_ENTITY_ID: self.state.entity_id,
|
||
media_player.ATTR_INPUT_SOURCE: self.get_ha_mode_by_yandex_mode(state.value),
|
||
},
|
||
blocking=self._wait_for_service_call,
|
||
context=context,
|
||
)
|
||
|
||
@property
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
modes = self.state.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST, []) or []
|
||
filtered_modes = [m for m in modes if m not in ["Live TV"]] # #418
|
||
if filtered_modes or self.state.state not in (STATE_OFF, STATE_UNKNOWN):
|
||
self._cache.save_attr_value(self.state.entity_id, media_player.ATTR_INPUT_SOURCE_LIST, modes)
|
||
return modes
|
||
return self._cache.get_attr_value(self.state.entity_id, media_player.ATTR_INPUT_SOURCE_LIST) or []
|
||
|
||
@property
|
||
def _ha_value(self) -> Any:
|
||
return self.state.attributes.get(media_player.ATTR_INPUT_SOURCE)
|
||
|
||
|
||
class FanSpeedCapability(StateModeCapability, ABC):
|
||
"""Base capability to control a device fan speed."""
|
||
|
||
instance = ModeCapabilityInstance.FAN_SPEED
|
||
|
||
|
||
class FanSpeedCapabilityClimate(FanSpeedCapability):
|
||
"""Capability to control the fan speed of a climate device."""
|
||
|
||
_modes_map_default = {
|
||
ModeCapabilityMode.AUTO: [climate.FAN_AUTO, climate.FAN_ON, XiaomiFanMode.NATURE, "auto"],
|
||
ModeCapabilityMode.QUIET: ["level1", "Level1", "silent", "quiet", "low"],
|
||
ModeCapabilityMode.MIN: ["level2", "Level2", climate.FAN_LOW, TionFanSpeed.S2],
|
||
ModeCapabilityMode.LOW: ["level3", "Level3", climate.FAN_LOW, "low_medium", TionFanSpeed.S3],
|
||
ModeCapabilityMode.MEDIUM: ["level4", "Level4", climate.FAN_MEDIUM, "medium", "mid", TionFanSpeed.S4],
|
||
ModeCapabilityMode.NORMAL: ["level5", "Level5", "medium_high", "normal"],
|
||
ModeCapabilityMode.HIGH: ["level6", "Level6", climate.FAN_HIGH, "high", TionFanSpeed.S5],
|
||
ModeCapabilityMode.TURBO: ["level7", "Level7", "turbo", "strong", climate.FAN_FOCUS, TionFanSpeed.S6],
|
||
ModeCapabilityMode.MAX: ["level8", "Level8", "max", SmartThinQFanMode.MID_HIGH],
|
||
}
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
if self.state.domain == climate.DOMAIN and self._state_features & ClimateEntityFeature.FAN_MODE:
|
||
return super().supported
|
||
return False
|
||
|
||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||
await self._hass.services.async_call(
|
||
climate.DOMAIN,
|
||
climate.SERVICE_SET_FAN_MODE,
|
||
{
|
||
ATTR_ENTITY_ID: self.state.entity_id,
|
||
climate.ATTR_FAN_MODE: self.get_ha_mode_by_yandex_mode(state.value),
|
||
},
|
||
blocking=self._wait_for_service_call,
|
||
context=context,
|
||
)
|
||
|
||
@property
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
modes = self.state.attributes.get(climate.ATTR_FAN_MODES, []) or []
|
||
if self._ha_value == climate.FAN_ON and climate.FAN_ON not in modes:
|
||
modes.append(climate.FAN_ON)
|
||
return modes
|
||
|
||
@property
|
||
def _ha_value(self) -> Any:
|
||
return self.state.attributes.get(climate.ATTR_FAN_MODE)
|
||
|
||
|
||
class FanSpeedCapabilityFanViaPreset(FanSpeedCapability):
|
||
"""Capability to control the fan speed of a fan device via preset."""
|
||
|
||
_modes_map_default = {
|
||
ModeCapabilityMode.AUTO: [climate.FAN_AUTO, climate.FAN_ON],
|
||
ModeCapabilityMode.ECO: [XiaomiFanMode.IDLE],
|
||
ModeCapabilityMode.QUIET: [climate.FAN_OFF, XiaomiFanMode.SILENT, XiaomiMiotFanMode.LEVEL_1],
|
||
ModeCapabilityMode.LOW: [XiaomiMiotFanMode.LEVEL_2],
|
||
ModeCapabilityMode.MEDIUM: [XiaomiHumidifierMode.MID, XiaomiMiotFanMode.LEVEL_3],
|
||
ModeCapabilityMode.NORMAL: [XiaomiFanMode.FAVORITE],
|
||
ModeCapabilityMode.HIGH: [XiaomiMiotFanMode.LEVEL_4],
|
||
ModeCapabilityMode.TURBO: [XiaomiFanMode.STRONG, XiaomiMiotFanMode.LEVEL_5],
|
||
}
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
if self.state.domain == fan.DOMAIN:
|
||
if self._state_features & FanEntityFeature.PRESET_MODE:
|
||
if (
|
||
self._state_features & FanEntityFeature.SET_SPEED
|
||
and fan.ATTR_PERCENTAGE_STEP in self.state.attributes
|
||
):
|
||
return False
|
||
return super().supported
|
||
return False
|
||
|
||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||
await self._hass.services.async_call(
|
||
fan.DOMAIN,
|
||
fan.SERVICE_SET_PRESET_MODE,
|
||
{
|
||
ATTR_ENTITY_ID: self.state.entity_id,
|
||
fan.ATTR_PRESET_MODE: self.get_ha_mode_by_yandex_mode(state.value),
|
||
},
|
||
blocking=self._wait_for_service_call,
|
||
context=context,
|
||
)
|
||
|
||
@property
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
return self.state.attributes.get(fan.ATTR_PRESET_MODES, []) or []
|
||
|
||
@property
|
||
def _ha_value(self) -> Any:
|
||
return self.state.attributes.get(fan.ATTR_PRESET_MODE)
|
||
|
||
|
||
class FanSpeedCapabilityFanViaPercentage(FanSpeedCapability):
|
||
"""Capability to control the fan speed in percents of a fan device."""
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
if self.state.domain == fan.DOMAIN:
|
||
if (
|
||
self._state_features & fan.FanEntityFeature.SET_SPEED
|
||
and fan.ATTR_PERCENTAGE_STEP in self.state.attributes
|
||
):
|
||
return super().supported
|
||
return False
|
||
|
||
@property
|
||
def supported_yandex_modes(self) -> list[ModeCapabilityMode]:
|
||
return [ModeCapabilityMode(m) for m in self.supported_ha_modes]
|
||
|
||
def get_value(self) -> ModeCapabilityMode | None:
|
||
if not self._ha_value:
|
||
return None
|
||
value = int(self._ha_value)
|
||
if self.modes_map:
|
||
for yandex_mode, values in self.modes_map.items():
|
||
for str_value in values:
|
||
if value == self._convert_mapping_speed_value(str_value):
|
||
return yandex_mode
|
||
return None
|
||
return ModeCapabilityMode(percentage_to_ordered_list_item(self.supported_ha_modes, value))
|
||
|
||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||
if self.modes_map:
|
||
ha_modes = self.modes_map.get(state.value)
|
||
if not ha_modes:
|
||
raise APIError(
|
||
ResponseCode.INVALID_VALUE,
|
||
f"Unsupported mode '{state.value}' for {self}, see https://docs.yaha-cloud.ru/v1.0.x/config/modes/",
|
||
)
|
||
ha_mode = self._convert_mapping_speed_value(ha_modes[0])
|
||
else:
|
||
ha_mode = ordered_list_item_to_percentage(self.supported_ha_modes, state.value)
|
||
|
||
await self._hass.services.async_call(
|
||
fan.DOMAIN,
|
||
fan.SERVICE_SET_PERCENTAGE,
|
||
{ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_PERCENTAGE: ha_mode},
|
||
blocking=self._wait_for_service_call,
|
||
context=context,
|
||
)
|
||
|
||
@property
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
if self.modes_map:
|
||
return self.modes_map.keys()
|
||
percentage_step = self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP, 100)
|
||
speed_count = math.ceil(100 / percentage_step)
|
||
if speed_count == 1:
|
||
return []
|
||
modes = [ModeCapabilityMode.LOW, ModeCapabilityMode.HIGH]
|
||
if speed_count >= 3:
|
||
modes.insert(modes.index(ModeCapabilityMode.HIGH), ModeCapabilityMode.MEDIUM)
|
||
if speed_count >= 4:
|
||
modes.insert(modes.index(ModeCapabilityMode.MEDIUM), ModeCapabilityMode.NORMAL)
|
||
if speed_count >= 5:
|
||
modes.insert(0, ModeCapabilityMode.ECO)
|
||
if speed_count >= 6:
|
||
modes.insert(modes.index(ModeCapabilityMode.LOW), ModeCapabilityMode.QUIET)
|
||
if speed_count >= 7:
|
||
modes.append(ModeCapabilityMode.TURBO)
|
||
return modes
|
||
|
||
@property
|
||
def _ha_value(self) -> Any:
|
||
return self.state.attributes.get(fan.ATTR_PERCENTAGE)
|
||
|
||
def _convert_mapping_speed_value(self, value: str) -> int:
|
||
try:
|
||
return int(value.replace("%", ""))
|
||
except ValueError:
|
||
raise APIError(ResponseCode.INVALID_VALUE, f"Unsupported speed value '{value}' for {self}")
|
||
|
||
|
||
class CleanupModeCapability(StateModeCapability):
|
||
"""Capability to control the program of a vacuum."""
|
||
|
||
instance = ModeCapabilityInstance.CLEANUP_MODE
|
||
_modes_map_default = {
|
||
ModeCapabilityMode.ECO: [RoborockCleanupMode.OFF],
|
||
ModeCapabilityMode.AUTO: [RoombaCleanupMode.AUTOMATIC, RoborockCleanupMode.BALANCED],
|
||
ModeCapabilityMode.TURBO: [
|
||
GenericMode.MAX_PLUS_SIGN,
|
||
RoborockCleanupMode.TURBO,
|
||
RoombaCleanupMode.PERFORMANCE,
|
||
XiaomiMiotCleanupMode.FULL_SPEED,
|
||
],
|
||
ModeCapabilityMode.LOW: [GenericMode.GENTLE],
|
||
ModeCapabilityMode.MAX: [RoborockCleanupMode.MAX, XiaomiMiotCleanupMode.STRONG],
|
||
ModeCapabilityMode.FAST: [RoborockCleanupMode.MAX_PLUS],
|
||
ModeCapabilityMode.NORMAL: [RoombaCleanupMode.STANDARD, XiaomiMiotCleanupMode.BASIC],
|
||
ModeCapabilityMode.QUIET: [RoborockCleanupMode.SILENT, RoombaCleanupMode.ECO],
|
||
}
|
||
|
||
@property
|
||
def supported(self) -> bool:
|
||
if self.state.domain == vacuum.DOMAIN and self._state_features & VacuumEntityFeature.FAN_SPEED:
|
||
return super().supported
|
||
return False
|
||
|
||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||
await self._hass.services.async_call(
|
||
vacuum.DOMAIN,
|
||
vacuum.SERVICE_SET_FAN_SPEED,
|
||
{
|
||
ATTR_ENTITY_ID: self.state.entity_id,
|
||
vacuum.ATTR_FAN_SPEED: self.get_ha_mode_by_yandex_mode(state.value),
|
||
},
|
||
blocking=self._wait_for_service_call,
|
||
context=context,
|
||
)
|
||
|
||
@property
|
||
def _ha_modes(self) -> Iterable[Any]:
|
||
return self.state.attributes.get(vacuum.ATTR_FAN_SPEED_LIST, []) or []
|
||
|
||
@property
|
||
def _ha_value(self) -> Any:
|
||
return self.state.attributes.get(vacuum.ATTR_FAN_SPEED)
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# Регистрация всех capabilities
|
||
# ──────────────────────────────────────────────
|
||
|
||
STATE_CAPABILITIES_REGISTRY.register(ThermostatCapability)
|
||
STATE_CAPABILITIES_REGISTRY.register(SwingCapability)
|
||
STATE_CAPABILITIES_REGISTRY.register(ProgramCapabilityClimate)
|
||
STATE_CAPABILITIES_REGISTRY.register(ProgramCapabilityHumidifier)
|
||
STATE_CAPABILITIES_REGISTRY.register(ProgramCapabilityFan)
|
||
STATE_CAPABILITIES_REGISTRY.register(InputSourceCapability)
|
||
STATE_CAPABILITIES_REGISTRY.register(FanSpeedCapabilityClimate)
|
||
STATE_CAPABILITIES_REGISTRY.register(FanSpeedCapabilityFanViaPreset)
|
||
STATE_CAPABILITIES_REGISTRY.register(FanSpeedCapabilityFanViaPercentage)
|
||
STATE_CAPABILITIES_REGISTRY.register(CleanupModeCapability) |