"""Implement the Yandex Smart Home range capabilities.""" from abc import ABC, abstractmethod from functools import cached_property import logging import math from typing import Any, Protocol from homeassistant.components import climate, cover, fan, humidifier, light, media_player, valve, water_heater from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.light import ColorMode from homeassistant.components.media_player import MediaPlayerDeviceClass, MediaPlayerEntityFeature, MediaType from homeassistant.components.valve import ValveEntityFeature from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_MODEL, ATTR_TEMPERATURE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_SET_COVER_POSITION, SERVICE_SET_VALVE_POSITION, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context from homeassistant.util.color import RGBColor from .capability import STATE_CAPABILITIES_REGISTRY, Capability, StateCapability from .capability_color import LightState from .const import ( ATTR_TARGET_HUMIDITY, CONF_ENTITY_RANGE, CONF_ENTITY_RANGE_MAX, CONF_ENTITY_RANGE_MIN, CONF_ENTITY_RANGE_PRECISION, CONF_FEATURES, CONF_SUPPORT_SET_CHANNEL, DOMAIN_XIAOMI_AIRPURIFIER, MODEL_PREFIX_XIAOMI_AIRPURIFIER, SERVICE_FAN_SET_TARGET_HUMIDITY, STATE_NONE, MediaPlayerFeature, ) from .helpers import APIError from .schema import ( CapabilityType, RangeCapabilityInstance, RangeCapabilityInstanceActionState, RangeCapabilityParameters, RangeCapabilityRange, ResponseCode, ) _LOGGER = logging.getLogger(__name__) class RangeCapability(Capability[RangeCapabilityInstanceActionState], Protocol): """Base class for capabilities with range functionality like volume or brightness. https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-docpage/ """ type: CapabilityType = CapabilityType.RANGE instance: RangeCapabilityInstance @property @abstractmethod def support_random_access(self) -> bool: """Test if the capability accept arbitrary values to be set.""" ... @abstractmethod def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" ... @property def retrievable(self) -> bool: """Test if the capability can return the current value.""" return self.support_random_access @property def parameters(self) -> RangeCapabilityParameters: """Return parameters for a devices list request.""" if self.support_random_access: return RangeCapabilityParameters(instance=self.instance, random_access=True, range=self._range) if self.instance in [ RangeCapabilityInstance.BRIGHTNESS, RangeCapabilityInstance.HUMIDITY, RangeCapabilityInstance.OPEN, RangeCapabilityInstance.TEMPERATURE, ]: return RangeCapabilityParameters( instance=self.instance, random_access=self.support_random_access, range=self._range ) return RangeCapabilityParameters(instance=self.instance, random_access=False) def get_value(self) -> float | None: """Return the current capability value.""" value = self._get_value() if self.support_random_access and value is not None: if not (self._range.min <= value <= self._range.max): _LOGGER.debug( f"Value {value} is not in range {self._range} for instance {self.instance.value} " f"of {self.device_id}" ) return None return value @abstractmethod async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" ... @abstractmethod def _get_absolute_value(self, relative_value: float) -> float: """Return the absolute value for a relative value.""" ... def _get_service_call_value(self, state: RangeCapabilityInstanceActionState) -> float: """Return the absolute value for a service call.""" if state.relative: return self._get_absolute_value(state.value) return state.value @cached_property def _range(self) -> RangeCapabilityRange: """Return supporting value range.""" return RangeCapabilityRange(min=0, max=100, precision=1) def _convert_to_float(self, value: Any, strict: bool = True) -> float | None: """Return float of a value, ignore some states, catch errors.""" if str(value).lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE): return None try: return float(value) except (ValueError, TypeError): if strict: raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Unsupported value '{value}' for {self}") return None class StateRangeCapability(RangeCapability, StateCapability[RangeCapabilityInstanceActionState], Protocol): """Base class for a range capability based on the state.""" def _get_absolute_value(self, relative_value: float) -> float: """Return the absolute value for a relative value.""" value = self._get_value() if value is None: if self.state.state == STATE_OFF: raise APIError(ResponseCode.DEVICE_OFF, f"Device {self.state.entity_id} probably turned off") raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Missing current value for {self}") return max(min(value + relative_value, self._range.max), self._range.min) class CoverPositionCapability(StateRangeCapability): """Capability to control position of a cover.""" instance = RangeCapabilityInstance.OPEN @property def supported(self) -> bool: """Test if the capability is supported.""" return self.state.domain == cover.DOMAIN and bool(self._state_features & CoverEntityFeature.SET_POSITION) @property def support_random_access(self) -> bool: """Test if the capability accept arbitrary values to be set.""" return True async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" await self._hass.services.async_call( cover.DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: self.state.entity_id, cover.ATTR_POSITION: self._get_service_call_value(state)}, blocking=self._wait_for_service_call, context=context, ) def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" return self._convert_to_float(self.state.attributes.get(cover.ATTR_CURRENT_POSITION)) class TemperatureCapability(StateRangeCapability, ABC): """Capability to control a device target temperature.""" instance = RangeCapabilityInstance.TEMPERATURE @property def support_random_access(self) -> bool: """Test if the capability accept arbitrary values to be set.""" return True class TemperatureCapabilityWaterHeater(TemperatureCapability): """Capability to control a water heater target temperature.""" @property def supported(self) -> bool: """Test if the capability is supported.""" return self.state.domain == water_heater.DOMAIN and bool( self._state_features & WaterHeaterEntityFeature.TARGET_TEMPERATURE ) async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" await self._hass.services.async_call( water_heater.DOMAIN, water_heater.SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: self._get_service_call_value(state)}, blocking=self._wait_for_service_call, context=context, ) def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" return self._convert_to_float(self.state.attributes.get(ATTR_TEMPERATURE)) @cached_property def _range(self) -> RangeCapabilityRange: """Return supporting value range.""" return RangeCapabilityRange( min=self.state.attributes.get(water_heater.ATTR_MIN_TEMP, 0), max=self.state.attributes.get(water_heater.ATTR_MAX_TEMP, 100), precision=0.5, ) class TemperatureCapabilityClimate(TemperatureCapability): """Capability to control a climate device target temperature.""" @property def supported(self) -> bool: """Test if the capability is supported.""" return self.state.domain == climate.DOMAIN and bool( self._state_features & ClimateEntityFeature.TARGET_TEMPERATURE ) async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" await self._hass.services.async_call( climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: self._get_service_call_value(state)}, blocking=self._wait_for_service_call, context=context, ) def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" return self._convert_to_float(self.state.attributes.get(ATTR_TEMPERATURE)) @cached_property def _range(self) -> RangeCapabilityRange: """Return supporting value range.""" return RangeCapabilityRange( min=self.state.attributes.get(climate.ATTR_MIN_TEMP, 0), max=self.state.attributes.get(climate.ATTR_MAX_TEMP, 100), precision=self.state.attributes.get(climate.ATTR_TARGET_TEMP_STEP, 0.5), ) class HumidityCapability(StateRangeCapability, ABC): """Capability to control a device target humidity.""" instance = RangeCapabilityInstance.HUMIDITY @property def support_random_access(self) -> bool: """Test if the capability accept arbitrary values to be set.""" return True class HumidityCapabilityHumidifier(HumidityCapability): """Capability to control a humidifier target humidity.""" @property def supported(self) -> bool: """Test if the capability is supported.""" return self.state.domain == humidifier.DOMAIN async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" await self._hass.services.async_call( humidifier.DOMAIN, humidifier.SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: self.state.entity_id, humidifier.ATTR_HUMIDITY: self._get_service_call_value(state)}, blocking=self._wait_for_service_call, context=context, ) def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" return self._convert_to_float(self.state.attributes.get(humidifier.ATTR_HUMIDITY)) @cached_property def _range(self) -> RangeCapabilityRange: """Return supporting value range.""" return RangeCapabilityRange( min=self.state.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 0), max=self.state.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 100), precision=1, ) class HumidityCapabilityXiaomiFan(HumidityCapability): """Capability to control a Xiaomi fan target humidity.""" @property def supported(self) -> bool: """Test if the capability is supported.""" if self.state.domain == fan.DOMAIN: if self.state.attributes.get(ATTR_MODEL, "").startswith(MODEL_PREFIX_XIAOMI_AIRPURIFIER): if ATTR_TARGET_HUMIDITY in self.state.attributes: return True return False async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" await self._hass.services.async_call( DOMAIN_XIAOMI_AIRPURIFIER, SERVICE_FAN_SET_TARGET_HUMIDITY, {ATTR_ENTITY_ID: self.state.entity_id, humidifier.ATTR_HUMIDITY: self._get_service_call_value(state)}, blocking=self._wait_for_service_call, context=context, ) def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" return self._convert_to_float(self.state.attributes.get(ATTR_TARGET_HUMIDITY)) class BrightnessCapability(StateRangeCapability): """Capability to control brightness of a device.""" instance = RangeCapabilityInstance.BRIGHTNESS @property def supported(self) -> bool: """Test if the capability is supported.""" return self.state.domain == light.DOMAIN and light.brightness_supported( self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) ) @property def support_random_access(self) -> bool: """Test if the capability accept arbitrary values to be set.""" return True async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" if state.relative: attribute = light.ATTR_BRIGHTNESS_STEP_PCT else: attribute = light.ATTR_BRIGHTNESS_PCT await self._hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: self.state.entity_id, attribute: state.value}, blocking=self._wait_for_service_call, context=context, ) def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" if (brightness := self._convert_to_float(self.state.attributes.get(light.ATTR_BRIGHTNESS))) is not None: return int(100 * (brightness / 255)) return None @cached_property def _range(self) -> RangeCapabilityRange: """Return supporting value range.""" return RangeCapabilityRange(min=1, max=100, precision=1) class WhiteLightBrightnessCapability(StateRangeCapability, LightState): """Capability to control white brightness and cold white brightness of a RGBW/RGBWW light device.""" instance = RangeCapabilityInstance.VOLUME volume_default_relative_step = 3 brightness_relative_step = 20 @property def supported(self) -> bool: """Test if the capability is supported.""" return self.state.domain == light.DOMAIN and bool( {ColorMode.RGBW, ColorMode.RGBWW} & self._supported_color_modes ) @property def support_random_access(self) -> bool: """Test if the capability accept arbitrary values to be set.""" return True async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" service_data: dict[str, Any] = {ATTR_ENTITY_ID: self.state.entity_id} color = self._rgb_color or RGBColor(0, 0, 0) brightness_pct = state.value if state.relative: if abs(state.value) == self.volume_default_relative_step: brightness_pct = self._get_absolute_value(self.brightness_relative_step * math.copysign(1, state.value)) else: brightness_pct = self._get_absolute_value(state.value) brightness = round(255 * brightness_pct / 100) if ColorMode.RGBWW in self._supported_color_modes: service_data[light.ATTR_RGBWW_COLOR] = color + (brightness, self._warm_white_brightness or 0) else: service_data[light.ATTR_RGBW_COLOR] = color + (brightness,) await self._hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, service_data, blocking=self._wait_for_service_call, context=context ) def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" if (value := self._white_brightness) is not None: return int(100 * (value / 255)) return None @cached_property def _range(self) -> RangeCapabilityRange: """Return supporting value range.""" return RangeCapabilityRange(min=0, max=100, precision=1) class WarmWhiteLightBrightnessCapability(StateRangeCapability, LightState): """Capability to control warm white brightness of a RGBWW light device.""" instance = RangeCapabilityInstance.OPEN @property def supported(self) -> bool: """Test if the capability is supported.""" return self.state.domain == light.DOMAIN and ColorMode.RGBWW in self._supported_color_modes @property def support_random_access(self) -> bool: """Test if the capability accept arbitrary values to be set.""" return True async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" color = self._rgb_color or RGBColor(0, 0, 0) brightness_pct = self._get_service_call_value(state) await self._hass.services.async_call( light.DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_RGBWW_COLOR: color + (self._white_brightness or 0, round(255 * brightness_pct / 100)), }, blocking=self._wait_for_service_call, context=context, ) def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" if (value := self._warm_white_brightness) is not None: return int(100 * (value / 255)) return None @cached_property def _range(self) -> RangeCapabilityRange: """Return supporting value range.""" return RangeCapabilityRange(min=0, max=100, precision=1) class VolumeCapability(StateRangeCapability): """Capability to control volume of a device.""" instance = RangeCapabilityInstance.VOLUME @property def supported(self) -> bool: """Test if the capability is supported.""" if self.state.domain == media_player.DOMAIN: if self._state_features & MediaPlayerEntityFeature.VOLUME_STEP: return True if self._state_features & MediaPlayerEntityFeature.VOLUME_SET: return True if MediaPlayerFeature.VOLUME_SET in self._entity_config.get(CONF_FEATURES, []): return True return False @property def support_random_access(self) -> bool: """Test if the capability accept arbitrary values to be set.""" if MediaPlayerFeature.VOLUME_SET in self._entity_config.get(CONF_FEATURES, []): return True return not ( self._state_features & MediaPlayerEntityFeature.VOLUME_STEP and not self._state_features & MediaPlayerEntityFeature.VOLUME_SET ) async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" if self.support_random_access: await self._hass.services.async_call( media_player.DOMAIN, SERVICE_VOLUME_SET, { ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_VOLUME_LEVEL: self._get_service_call_value(state) / 100, }, blocking=self._wait_for_service_call, context=context, ) return # absolute volume if not state.relative: raise APIError(ResponseCode.INVALID_VALUE, f"Absolute volume is not supported for {self}") if state.value > 0: service = SERVICE_VOLUME_UP else: service = SERVICE_VOLUME_DOWN volume_step = int(self._entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_PRECISION, 1)) if abs(state.value) != 1: volume_step = int(abs(state.value)) for _ in range(volume_step): await self._hass.services.async_call( media_player.DOMAIN, service, {ATTR_ENTITY_ID: self.state.entity_id}, blocking=self._wait_for_service_call, context=context, ) return None def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" if ( level := self._convert_to_float(self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL)) ) is not None: return int(level * 100) return None @cached_property def _range(self) -> RangeCapabilityRange: """Return supporting value range.""" return RangeCapabilityRange( min=self._entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MIN, 0), max=self._entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MAX, 100), precision=self._entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_PRECISION, 1), ) class ChannelCapability(StateRangeCapability): """Capability to control media playback state.""" instance = RangeCapabilityInstance.CHANNEL @property def supported(self) -> bool: """Test if the capability is supported.""" if self.state.domain == media_player.DOMAIN: if ( self._state_features & MediaPlayerEntityFeature.PREVIOUS_TRACK and self._state_features & MediaPlayerEntityFeature.NEXT_TRACK ): return True if MediaPlayerFeature.NEXT_PREVIOUS_TRACK in self._entity_config.get(CONF_FEATURES, []): return True if ( self._state_features & MediaPlayerEntityFeature.PLAY_MEDIA or MediaPlayerFeature.PLAY_MEDIA in self._entity_config.get(CONF_FEATURES, []) ): if self._entity_config.get(CONF_SUPPORT_SET_CHANNEL) is False: return False return True return False @property def support_random_access(self) -> bool: """Test if the capability accept arbitrary values to be set.""" device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) if self._entity_config.get(CONF_SUPPORT_SET_CHANNEL) is False: return False if device_class == MediaPlayerDeviceClass.TV: if ( self._state_features & MediaPlayerEntityFeature.PLAY_MEDIA or MediaPlayerFeature.PLAY_MEDIA in self._entity_config.get(CONF_FEATURES, []) ): return True return False async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" value = state.value if state.relative: if ( self._state_features & MediaPlayerEntityFeature.PREVIOUS_TRACK and self._state_features & MediaPlayerEntityFeature.NEXT_TRACK ): if state.value > 0: service = SERVICE_MEDIA_NEXT_TRACK else: service = SERVICE_MEDIA_PREVIOUS_TRACK await self._hass.services.async_call( media_player.DOMAIN, service, {ATTR_ENTITY_ID: self.state.entity_id}, blocking=self._wait_for_service_call, context=context, ) return if self.get_value() is None: raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Missing current value for {self}") else: value = self._get_absolute_value(state.value) try: await self._hass.services.async_call( media_player.DOMAIN, media_player.SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_CONTENT_ID: int(value), media_player.ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, }, blocking=False, # some tv's do it too slow context=context, ) except ValueError as e: raise APIError( ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Failed to set channel for {self.device_id}. " f'Please change setting "support_set_channel" to "false" in entity_config ' f"if the device does not support channel selection. Error: {e!r}", ) return None def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" media_content_type = self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_TYPE) if media_content_type == MediaType.CHANNEL: return self._convert_to_float(self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_ID), strict=False) return None @cached_property def _range(self) -> RangeCapabilityRange: """Return supporting value range.""" return RangeCapabilityRange( min=0, max=999, precision=1, ) class ValvePositionCapability(StateRangeCapability): """Capability to control position of a device.""" instance = RangeCapabilityInstance.OPEN @property def supported(self) -> bool: """Test if the capability is supported.""" return self.state.domain == valve.DOMAIN and bool(self._state_features & ValveEntityFeature.SET_POSITION) @property def support_random_access(self) -> bool: """Test if the capability accept arbitrary values to be set.""" return True async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: """Change the capability state.""" await self._hass.services.async_call( valve.DOMAIN, SERVICE_SET_VALVE_POSITION, {ATTR_ENTITY_ID: self.state.entity_id, valve.ATTR_POSITION: self._get_service_call_value(state)}, blocking=self._wait_for_service_call, context=context, ) def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" return self._convert_to_float(self.state.attributes.get(valve.ATTR_CURRENT_POSITION)) STATE_CAPABILITIES_REGISTRY.register(CoverPositionCapability) STATE_CAPABILITIES_REGISTRY.register(TemperatureCapabilityWaterHeater) STATE_CAPABILITIES_REGISTRY.register(TemperatureCapabilityClimate) STATE_CAPABILITIES_REGISTRY.register(HumidityCapabilityHumidifier) STATE_CAPABILITIES_REGISTRY.register(HumidityCapabilityXiaomiFan) STATE_CAPABILITIES_REGISTRY.register(BrightnessCapability) STATE_CAPABILITIES_REGISTRY.register(WhiteLightBrightnessCapability) STATE_CAPABILITIES_REGISTRY.register(WarmWhiteLightBrightnessCapability) STATE_CAPABILITIES_REGISTRY.register(VolumeCapability) STATE_CAPABILITIES_REGISTRY.register(ChannelCapability) STATE_CAPABILITIES_REGISTRY.register(ValvePositionCapability)