first commit
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
"""Implement the Yandex Smart Home ranges capabilities."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import climate, cover, fan, humidifier, light, media_player, water_heater
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_MODEL,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
STATE_OFF,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
|
||||
from . import const
|
||||
from .capability import PREFIX_CAPABILITIES, AbstractCapability, register_capability
|
||||
from .const import (
|
||||
ATTR_TARGET_HUMIDITY,
|
||||
CONF_ENTITY_RANGE,
|
||||
CONF_ENTITY_RANGE_MAX,
|
||||
CONF_ENTITY_RANGE_MIN,
|
||||
CONF_ENTITY_RANGE_PRECISION,
|
||||
DOMAIN_XIAOMI_AIRPURIFIER,
|
||||
ERR_DEVICE_OFF,
|
||||
ERR_INVALID_VALUE,
|
||||
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
MODEL_PREFIX_XIAOMI_AIRPURIFIER,
|
||||
SERVICE_FAN_SET_TARGET_HUMIDITY,
|
||||
STATE_NONE,
|
||||
)
|
||||
from .error import SmartHomeError
|
||||
from .helpers import Config, RequestData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CAPABILITIES_RANGE = PREFIX_CAPABILITIES + 'range'
|
||||
|
||||
|
||||
class RangeCapability(AbstractCapability, ABC):
|
||||
"""Base class of capabilities with range functionality like volume or
|
||||
brightness.
|
||||
|
||||
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-docpage/
|
||||
"""
|
||||
|
||||
type = CAPABILITIES_RANGE
|
||||
default_range = (0, 100, 1)
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Config, state: State):
|
||||
super().__init__(hass, config, state)
|
||||
self.retrievable = self.support_random_access
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def support_random_access(self) -> bool:
|
||||
"""Test if capability supports random access."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def range(self) -> (float, float, float):
|
||||
"""Return support range (min, max, precision)."""
|
||||
return (
|
||||
self.entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MIN, self.default_range[0]),
|
||||
self.entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MAX, self.default_range[1]),
|
||||
self.entity_config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_PRECISION, self.default_range[2])
|
||||
)
|
||||
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
"""Return parameters for a devices request."""
|
||||
if self.support_random_access:
|
||||
range_min, range_max, range_precision = self.range
|
||||
rv = {
|
||||
'instance': self.instance,
|
||||
'random_access': True,
|
||||
'range': {
|
||||
'min': range_min,
|
||||
'max': range_max,
|
||||
'precision': range_precision
|
||||
}
|
||||
}
|
||||
|
||||
if self.instance in const.RANGE_INSTANCE_TO_UNITS:
|
||||
rv['unit'] = const.RANGE_INSTANCE_TO_UNITS[self.instance]
|
||||
|
||||
return rv
|
||||
|
||||
return {
|
||||
'instance': self.instance,
|
||||
'random_access': False,
|
||||
}
|
||||
|
||||
def float_value(self, value: Any, strict: bool = True) -> float | None:
|
||||
if str(value).lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE):
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
if strict:
|
||||
raise SmartHomeError(
|
||||
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
f'Unsupported value {value!r} for instance {self.instance} of {self.state.entity_id}'
|
||||
)
|
||||
|
||||
def get_absolute_value(self, relative_value: float) -> float:
|
||||
"""Return absolute value for relative value."""
|
||||
value = self.get_value()
|
||||
if value is None:
|
||||
if self.state.state == STATE_OFF:
|
||||
raise SmartHomeError(
|
||||
ERR_DEVICE_OFF,
|
||||
f'Device {self.state.entity_id} probably turned off'
|
||||
)
|
||||
|
||||
raise SmartHomeError(
|
||||
ERR_INVALID_VALUE,
|
||||
f'Unable to get current value or {self.instance} instance of {self.state.entity_id}'
|
||||
)
|
||||
|
||||
return max(min(value + relative_value, self.range[1]), self.range[0])
|
||||
|
||||
|
||||
@register_capability
|
||||
class CoverLevelCapability(RangeCapability):
|
||||
"""Set cover level"""
|
||||
|
||||
instance = const.RANGE_INSTANCE_OPEN
|
||||
|
||||
def supported(self) -> bool:
|
||||
"""Test if capability is supported."""
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
return self.state.domain == cover.DOMAIN and features & cover.CoverEntityFeature.SET_POSITION
|
||||
|
||||
@property
|
||||
def support_random_access(self) -> bool:
|
||||
"""Test if capability supports random access."""
|
||||
return True
|
||||
|
||||
def get_value(self) -> float | None:
|
||||
"""Return the state value of this capability for this entity."""
|
||||
return self.float_value(self.state.attributes.get(cover.ATTR_CURRENT_POSITION))
|
||||
|
||||
async def set_state(self, data: RequestData, state: dict[str, Any]):
|
||||
"""Set device state."""
|
||||
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
|
||||
|
||||
await self.hass.services.async_call(
|
||||
cover.DOMAIN,
|
||||
cover.SERVICE_SET_COVER_POSITION, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
cover.ATTR_POSITION: value
|
||||
},
|
||||
blocking=True,
|
||||
context=data.context
|
||||
)
|
||||
|
||||
|
||||
class TemperatureCapability(RangeCapability, ABC):
|
||||
"""Set temperature functionality."""
|
||||
|
||||
instance = const.RANGE_INSTANCE_TEMPERATURE
|
||||
default_range = (0, 100, 0.5)
|
||||
|
||||
@property
|
||||
def support_random_access(self) -> bool:
|
||||
"""Test if capability supports random access."""
|
||||
return True
|
||||
|
||||
|
||||
@register_capability
|
||||
class TemperatureCapabilityWaterHeater(TemperatureCapability):
|
||||
def __init__(self, hass: HomeAssistant, config: Config, state: State):
|
||||
super().__init__(hass, config, state)
|
||||
|
||||
self.default_range = (
|
||||
self.state.attributes.get(water_heater.ATTR_MIN_TEMP),
|
||||
self.state.attributes.get(water_heater.ATTR_MAX_TEMP),
|
||||
0.5
|
||||
)
|
||||
|
||||
def supported(self) -> bool:
|
||||
"""Test if capability is supported."""
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
return self.state.domain == water_heater.DOMAIN and \
|
||||
features & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
def get_value(self) -> float | None:
|
||||
"""Return the state value of this capability for this entity."""
|
||||
return self.float_value(self.state.attributes.get(water_heater.ATTR_TEMPERATURE))
|
||||
|
||||
async def set_state(self, data: RequestData, state: dict[str, Any]):
|
||||
"""Set device state."""
|
||||
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
|
||||
|
||||
await self.hass.services.async_call(
|
||||
water_heater.DOMAIN,
|
||||
water_heater.SERVICE_SET_TEMPERATURE, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
water_heater.ATTR_TEMPERATURE: value
|
||||
},
|
||||
blocking=True,
|
||||
context=data.context
|
||||
)
|
||||
|
||||
|
||||
@register_capability
|
||||
class TemperatureCapabilityClimate(TemperatureCapability):
|
||||
def __init__(self, hass: HomeAssistant, config: Config, state: State):
|
||||
super().__init__(hass, config, state)
|
||||
|
||||
self.default_range = (
|
||||
self.state.attributes.get(climate.ATTR_MIN_TEMP),
|
||||
self.state.attributes.get(climate.ATTR_MAX_TEMP),
|
||||
self.state.attributes.get(climate.ATTR_TARGET_TEMP_STEP, 0.5),
|
||||
)
|
||||
|
||||
def supported(self) -> bool:
|
||||
"""Test if capability is supported."""
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
return self.state.domain == climate.DOMAIN and features & climate.ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
def get_value(self) -> float | None:
|
||||
"""Return the state value of this capability for this entity."""
|
||||
return self.float_value(self.state.attributes.get(climate.ATTR_TEMPERATURE))
|
||||
|
||||
async def set_state(self, data: RequestData, state: dict[str, Any]):
|
||||
"""Set device state."""
|
||||
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
|
||||
|
||||
await self.hass.services.async_call(
|
||||
climate.DOMAIN,
|
||||
climate.SERVICE_SET_TEMPERATURE, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
climate.ATTR_TEMPERATURE: value
|
||||
},
|
||||
blocking=True,
|
||||
context=data.context
|
||||
)
|
||||
|
||||
|
||||
class HumidityCapability(RangeCapability, ABC):
|
||||
"""Set humidity functionality."""
|
||||
|
||||
instance = const.RANGE_INSTANCE_HUMIDITY
|
||||
|
||||
@property
|
||||
def support_random_access(self) -> bool:
|
||||
"""Test if capability supports random access."""
|
||||
return True
|
||||
|
||||
|
||||
@register_capability
|
||||
class HumidityCapabilityHumidifier(HumidityCapability):
|
||||
instance = const.RANGE_INSTANCE_HUMIDITY
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: Config, state: State):
|
||||
"""Initialize a trait for a state."""
|
||||
super().__init__(hass, config, state)
|
||||
|
||||
self.default_range = (
|
||||
self.state.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 0),
|
||||
self.state.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 100),
|
||||
1
|
||||
)
|
||||
|
||||
def supported(self) -> bool:
|
||||
"""Test if capability is supported."""
|
||||
return self.state.domain == humidifier.DOMAIN
|
||||
|
||||
def get_value(self) -> float | None:
|
||||
"""Return the state value of this capability for this entity."""
|
||||
return self.float_value(self.state.attributes.get(humidifier.ATTR_HUMIDITY))
|
||||
|
||||
async def set_state(self, data: RequestData, state: dict[str, Any]):
|
||||
"""Set device state."""
|
||||
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
|
||||
|
||||
await self.hass.services.async_call(
|
||||
humidifier.DOMAIN,
|
||||
humidifier.SERVICE_SET_HUMIDITY, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
humidifier.ATTR_HUMIDITY: value
|
||||
},
|
||||
blocking=True,
|
||||
context=data.context
|
||||
)
|
||||
|
||||
|
||||
@register_capability
|
||||
class HumidityCapabilityHumidiferXiaomi(HumidityCapability):
|
||||
"""Set humidity functionality."""
|
||||
|
||||
def supported(self) -> bool:
|
||||
"""Test if 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
|
||||
|
||||
def get_value(self) -> float | None:
|
||||
"""Return the state value of this capability for this entity."""
|
||||
return self.float_value(self.state.attributes.get(ATTR_TARGET_HUMIDITY))
|
||||
|
||||
async def set_state(self, data: RequestData, state: dict[str, Any]):
|
||||
"""Set device state."""
|
||||
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
|
||||
|
||||
await self.hass.services.async_call(
|
||||
DOMAIN_XIAOMI_AIRPURIFIER,
|
||||
SERVICE_FAN_SET_TARGET_HUMIDITY, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
humidifier.ATTR_HUMIDITY: value
|
||||
},
|
||||
blocking=True,
|
||||
context=data.context
|
||||
)
|
||||
|
||||
|
||||
@register_capability
|
||||
class BrightnessCapability(RangeCapability):
|
||||
"""Set brightness functionality."""
|
||||
|
||||
instance = const.RANGE_INSTANCE_BRIGHTNESS
|
||||
default_range = (1, 100, 1)
|
||||
|
||||
def supported(self) -> bool:
|
||||
"""Test if capability is supported."""
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if self.state.domain == light.DOMAIN:
|
||||
if features & light.SUPPORT_BRIGHTNESS:
|
||||
return True
|
||||
|
||||
if light.brightness_supported(self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def support_random_access(self) -> bool:
|
||||
"""Test if capability supports random access."""
|
||||
return True
|
||||
|
||||
def get_value(self) -> float | None:
|
||||
"""Return the state value of this capability for this entity."""
|
||||
brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS)
|
||||
if brightness is not None:
|
||||
return int(100 * (self.float_value(brightness) / 255))
|
||||
|
||||
async def set_state(self, data: RequestData, state: dict[str, Any]):
|
||||
"""Set device state."""
|
||||
if state.get('relative'):
|
||||
attribute = light.ATTR_BRIGHTNESS_STEP_PCT
|
||||
else:
|
||||
attribute = light.ATTR_BRIGHTNESS_PCT
|
||||
|
||||
await self.hass.services.async_call(
|
||||
light.DOMAIN,
|
||||
light.SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
attribute: state['value']
|
||||
},
|
||||
blocking=True,
|
||||
context=data.context
|
||||
)
|
||||
|
||||
|
||||
@register_capability
|
||||
class VolumeCapability(RangeCapability):
|
||||
"""Set volume functionality."""
|
||||
|
||||
instance = const.RANGE_INSTANCE_VOLUME
|
||||
|
||||
def supported(self) -> bool:
|
||||
"""Test if capability is supported."""
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if self.state.domain == media_player.DOMAIN:
|
||||
if features & media_player.MediaPlayerEntityFeature.VOLUME_STEP:
|
||||
return True
|
||||
|
||||
if features & media_player.MediaPlayerEntityFeature.VOLUME_SET:
|
||||
return True
|
||||
|
||||
if const.MEDIA_PLAYER_FEATURE_VOLUME_SET in self.entity_config.get(const.CONF_FEATURES, []):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def support_random_access(self) -> bool:
|
||||
"""Test if capability supports random access."""
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if const.MEDIA_PLAYER_FEATURE_VOLUME_SET in self.entity_config.get(const.CONF_FEATURES, []):
|
||||
return True
|
||||
|
||||
return not (features & media_player.MediaPlayerEntityFeature.VOLUME_STEP and
|
||||
not features & media_player.MediaPlayerEntityFeature.VOLUME_SET)
|
||||
|
||||
def get_value(self) -> float | None:
|
||||
"""Return the state value of this capability for this entity."""
|
||||
level = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL)
|
||||
|
||||
if level is not None:
|
||||
return int(self.float_value(level) * 100)
|
||||
|
||||
async def set_state(self, data: RequestData, state: dict[str, Any]):
|
||||
"""Set device state."""
|
||||
if not self.support_random_access:
|
||||
if not state.get('relative'):
|
||||
raise SmartHomeError(ERR_INVALID_VALUE, f'Failed to set absolute volume for {self.state.entity_id}')
|
||||
|
||||
if state['value'] > 0:
|
||||
service = media_player.SERVICE_VOLUME_UP
|
||||
else:
|
||||
service = media_player.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 = 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=True, context=data.context
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
value = (state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])) / 100
|
||||
|
||||
await self.hass.services.async_call(
|
||||
media_player.DOMAIN,
|
||||
media_player.SERVICE_VOLUME_SET, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL: value
|
||||
},
|
||||
blocking=True,
|
||||
context=data.context
|
||||
)
|
||||
|
||||
|
||||
@register_capability
|
||||
class ChannelCapability(RangeCapability):
|
||||
"""Set channel functionality."""
|
||||
|
||||
instance = const.RANGE_INSTANCE_CHANNEL
|
||||
default_range = (0, 999, 1)
|
||||
|
||||
def supported(self) -> bool:
|
||||
"""Test if capability is supported."""
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if self.state.domain == media_player.DOMAIN:
|
||||
if features & media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK and \
|
||||
features & media_player.MediaPlayerEntityFeature.NEXT_TRACK:
|
||||
return True
|
||||
|
||||
if const.MEDIA_PLAYER_FEATURE_NEXT_PREVIOUS_TRACK in self.entity_config.get(const.CONF_FEATURES, []):
|
||||
return True
|
||||
|
||||
if features & media_player.MediaPlayerEntityFeature.PLAY_MEDIA or \
|
||||
const.MEDIA_PLAYER_FEATURE_PLAY_MEDIA in self.entity_config.get(const.CONF_FEATURES, []):
|
||||
if self.entity_config.get(const.CONF_SUPPORT_SET_CHANNEL) is False:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def support_random_access(self) -> bool:
|
||||
"""Test if capability supports random access."""
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
device_class = self.state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
|
||||
if self.entity_config.get(const.CONF_SUPPORT_SET_CHANNEL) is False:
|
||||
return False
|
||||
|
||||
if device_class == media_player.DEVICE_CLASS_TV:
|
||||
if features & media_player.MediaPlayerEntityFeature.PLAY_MEDIA or \
|
||||
const.MEDIA_PLAYER_FEATURE_PLAY_MEDIA in self.entity_config.get(const.CONF_FEATURES, []):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_value(self) -> float | None:
|
||||
"""Return the state value of this capability for this entity."""
|
||||
media_content_type = self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_TYPE)
|
||||
|
||||
if media_content_type == media_player.const.MEDIA_TYPE_CHANNEL:
|
||||
return self.float_value(self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_ID), strict=False)
|
||||
|
||||
async def set_state(self, data: RequestData, state: dict[str, Any]):
|
||||
"""Set device state."""
|
||||
value = state['value']
|
||||
features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
if state.get('relative'):
|
||||
if features & media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK and \
|
||||
features & media_player.MediaPlayerEntityFeature.NEXT_TRACK:
|
||||
if state['value'] >= 0:
|
||||
service = media_player.SERVICE_MEDIA_NEXT_TRACK
|
||||
else:
|
||||
service = media_player.SERVICE_MEDIA_PREVIOUS_TRACK
|
||||
|
||||
await self.hass.services.async_call(
|
||||
media_player.DOMAIN,
|
||||
service, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id
|
||||
},
|
||||
blocking=True,
|
||||
context=data.context
|
||||
)
|
||||
return
|
||||
|
||||
if self.get_value() is None:
|
||||
raise SmartHomeError(
|
||||
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
f'Failed to set relative value for {self.instance} instance of {self.state.entity_id}.'
|
||||
)
|
||||
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: media_player.const.MEDIA_TYPE_CHANNEL
|
||||
},
|
||||
blocking=False, # some tv's do it too slow
|
||||
context=data.context
|
||||
)
|
||||
except ValueError as e:
|
||||
raise SmartHomeError(
|
||||
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
f'Failed to set channel for {self.state.entity_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}'
|
||||
)
|
||||
Reference in New Issue
Block a user