Initialize docker stack repo
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
"""The Yandex Smart Home component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_ID, CONF_PLATFORM, CONF_TOKEN, SERVICE_RELOAD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entityfilter import FILTER_SCHEMA, EntityFilter
|
||||
from homeassistant.helpers.reload import async_integration_yaml_config
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
import voluptuous as vol
|
||||
|
||||
from .config_schema import YANDEX_SMART_HOME_SCHEMA
|
||||
from .const import (
|
||||
CONF_CLOUD_INSTANCE,
|
||||
CONF_CONNECTION_TYPE,
|
||||
CONF_DEVICES_DISCOVERED,
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_FILTER,
|
||||
CONF_FILTER_SOURCE,
|
||||
CONF_LINKED_PLATFORMS,
|
||||
CONF_NOTIFIER,
|
||||
CONF_NOTIFIER_OAUTH_TOKEN,
|
||||
CONF_NOTIFIER_SKILL_ID,
|
||||
CONF_NOTIFIER_USER_ID,
|
||||
CONF_SKILL,
|
||||
CONF_USER_ID,
|
||||
DOMAIN,
|
||||
ConnectionType,
|
||||
EntityFilterSource,
|
||||
)
|
||||
from .entry_data import ConfigEntryData
|
||||
from .helpers import SmartHomePlatform
|
||||
from .http import async_register_http
|
||||
from .repairs import delete_unexposed_entity_found_issues
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .cloud_stream import CloudStreamManager
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({DOMAIN: YANDEX_SMART_HOME_SCHEMA}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
class YandexSmartHome:
|
||||
"""Yandex Smart Home component main class."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, yaml_config: ConfigType):
|
||||
"""Initialize the Yandex Smart Home from yaml configuration."""
|
||||
self.cloud_streams: dict[str, CloudStreamManager] = {}
|
||||
|
||||
self._hass = hass
|
||||
self._yaml_config = yaml_config
|
||||
self._entry_datas: dict[str, ConfigEntryData] = {}
|
||||
|
||||
async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, self._handle_yaml_config_reload)
|
||||
|
||||
async def _handle_yaml_config_reload(self, _: Any) -> None:
|
||||
"""Handle yaml configuration reloading."""
|
||||
if config := await async_integration_yaml_config(self._hass, DOMAIN):
|
||||
self._yaml_config = config.get(DOMAIN, {})
|
||||
|
||||
for entry in self._hass.config_entries.async_entries(DOMAIN):
|
||||
await _async_entry_update_listener(self._hass, entry)
|
||||
|
||||
return None
|
||||
|
||||
def get_entry_data(self, entry: ConfigEntry) -> ConfigEntryData:
|
||||
"""Return a config entry data for a config entry."""
|
||||
return self._entry_datas[entry.entry_id]
|
||||
|
||||
def get_direct_connection_entry_data(
|
||||
self, platform: SmartHomePlatform, user_id: str | None
|
||||
) -> ConfigEntryData | None:
|
||||
"""Return a config entry data with direct connection config entry."""
|
||||
for data in self._entry_datas.values():
|
||||
if (
|
||||
data.connection_type == ConnectionType.DIRECT
|
||||
and data.entry.state == ConfigEntryState.LOADED
|
||||
and data.platform == platform
|
||||
):
|
||||
if user_id and data.skill and data.skill.user_id == user_id:
|
||||
return data
|
||||
if not user_id:
|
||||
return data
|
||||
|
||||
return None
|
||||
|
||||
def get_diagnostics(self) -> ConfigType:
|
||||
"""Return diagnostics for the component."""
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
|
||||
return {"yaml_config": async_redact_data(self._yaml_config, [CONF_NOTIFIER])}
|
||||
|
||||
def get_entity_filter_from_yaml(self) -> EntityFilter | None:
|
||||
"""Return entity filter from yaml configuration."""
|
||||
if entity_filter_config := self._yaml_config.get(CONF_FILTER):
|
||||
return cast(EntityFilter, FILTER_SCHEMA(entity_filter_config))
|
||||
|
||||
return None
|
||||
|
||||
async def async_setup_entry(self, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
entity_config = self._yaml_config.get(CONF_ENTITY_CONFIG)
|
||||
|
||||
entity_filter: EntityFilter | None = None
|
||||
if entry.options.get(CONF_FILTER_SOURCE) == EntityFilterSource.YAML:
|
||||
entity_filter = self.get_entity_filter_from_yaml()
|
||||
else:
|
||||
entity_filter = FILTER_SCHEMA(entry.options.get(CONF_FILTER, {}))
|
||||
|
||||
data = ConfigEntryData(
|
||||
hass=self._hass,
|
||||
entry=entry,
|
||||
yaml_config=self._yaml_config,
|
||||
entity_config=entity_config,
|
||||
entity_filter=entity_filter,
|
||||
)
|
||||
|
||||
self._entry_datas[entry.entry_id] = await data.async_setup()
|
||||
entry.async_on_unload(entry.add_update_listener(_async_entry_update_listener))
|
||||
delete_unexposed_entity_found_issues(self._hass)
|
||||
|
||||
return True
|
||||
|
||||
async def async_unload_entry(self, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
delete_unexposed_entity_found_issues(self._hass)
|
||||
data = self.get_entry_data(entry)
|
||||
await data.async_unload()
|
||||
return True
|
||||
|
||||
async def async_remove_entry(self, entry: ConfigEntry) -> None:
|
||||
"""Remove a config entry."""
|
||||
try:
|
||||
del self._entry_datas[entry.entry_id]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool:
|
||||
"""Activate Yandex Smart Home component."""
|
||||
hass.data[DOMAIN] = component = YandexSmartHome(hass, yaml_config.get(DOMAIN, {}))
|
||||
async_register_http(hass, component)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
component: YandexSmartHome = hass.data[DOMAIN]
|
||||
return await component.async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
component: YandexSmartHome = hass.data[DOMAIN]
|
||||
return await component.async_unload_entry(entry)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate the config entry upon new versions."""
|
||||
version = entry.version
|
||||
component: YandexSmartHome = hass.data[DOMAIN]
|
||||
data: ConfigType = {**entry.data}
|
||||
options: ConfigType = {**entry.options}
|
||||
|
||||
_LOGGER.debug(f"Migrating from version {version}")
|
||||
|
||||
if version == 1:
|
||||
preserve_keys = [
|
||||
CONF_CONNECTION_TYPE,
|
||||
CONF_CLOUD_INSTANCE,
|
||||
CONF_DEVICES_DISCOVERED,
|
||||
CONF_FILTER,
|
||||
CONF_USER_ID,
|
||||
]
|
||||
for store in [data, options]:
|
||||
for key in list(store.keys()):
|
||||
if key not in preserve_keys:
|
||||
store.pop(key, None)
|
||||
|
||||
data.setdefault(CONF_CONNECTION_TYPE, ConnectionType.DIRECT)
|
||||
data.setdefault(CONF_DEVICES_DISCOVERED, True)
|
||||
|
||||
version = 2
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options, version=version)
|
||||
_LOGGER.debug(f"Migration to version {version} successful")
|
||||
|
||||
if version == 2:
|
||||
version = 3
|
||||
_LOGGER.debug(f"Migration to version {version} successful")
|
||||
|
||||
if version == 3:
|
||||
options[CONF_FILTER_SOURCE] = EntityFilterSource.CONFIG_ENTRY
|
||||
if CONF_FILTER in component._yaml_config:
|
||||
options[CONF_FILTER_SOURCE] = EntityFilterSource.YAML
|
||||
|
||||
version = 4
|
||||
hass.config_entries.async_update_entry(entry, data=data, options=options, version=version)
|
||||
_LOGGER.debug(f"Migration to version {version} successful")
|
||||
|
||||
if version == 4:
|
||||
from .config_flow import DEFAULT_CONFIG_ENTRY_TITLE, PRE_V1_DIRECT_CONFIG_ENTRY_TITLE, async_config_entry_title
|
||||
|
||||
title = entry.title
|
||||
data.setdefault(CONF_PLATFORM, SmartHomePlatform.YANDEX)
|
||||
|
||||
if len(hass.config_entries.async_entries(DOMAIN)) == 1 and data[CONF_CONNECTION_TYPE] == ConnectionType.DIRECT:
|
||||
for notifier_config in component._yaml_config.get(CONF_NOTIFIER, []):
|
||||
options.setdefault(
|
||||
CONF_SKILL,
|
||||
{
|
||||
CONF_USER_ID: notifier_config[CONF_NOTIFIER_USER_ID],
|
||||
CONF_ID: notifier_config[CONF_NOTIFIER_SKILL_ID],
|
||||
CONF_TOKEN: notifier_config[CONF_NOTIFIER_OAUTH_TOKEN],
|
||||
},
|
||||
)
|
||||
break
|
||||
|
||||
if entry.title in (DEFAULT_CONFIG_ENTRY_TITLE, PRE_V1_DIRECT_CONFIG_ENTRY_TITLE):
|
||||
title = await async_config_entry_title(hass, data, options)
|
||||
|
||||
version = 5
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
title=title,
|
||||
data=data,
|
||||
options=options,
|
||||
version=version,
|
||||
)
|
||||
_LOGGER.debug(f"Migration to version {version} successful")
|
||||
|
||||
if version == 5:
|
||||
if data.get(CONF_DEVICES_DISCOVERED):
|
||||
data[CONF_LINKED_PLATFORMS] = [SmartHomePlatform.YANDEX]
|
||||
|
||||
version = 6
|
||||
hass.config_entries.async_update_entry(entry, data=data, version=version)
|
||||
_LOGGER.debug(f"Migration to version {version} successful")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Remove a config entry."""
|
||||
component: YandexSmartHome | None = hass.data.get(DOMAIN)
|
||||
if component:
|
||||
await component.async_remove_entry(entry)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _async_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle config entry options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
return None
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Backports from newer Home Assistant versions."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class LockState(StrEnum):
|
||||
"""State of lock entities (2024.10+)."""
|
||||
|
||||
JAMMED = "jammed"
|
||||
OPENING = "opening"
|
||||
LOCKING = "locking"
|
||||
OPEN = "open"
|
||||
UNLOCKING = "unlocking"
|
||||
LOCKED = "locked"
|
||||
UNLOCKED = "unlocked"
|
||||
|
||||
|
||||
class VacuumActivity(StrEnum):
|
||||
"""Vacuum activity states (2025.1+)."""
|
||||
|
||||
CLEANING = "cleaning"
|
||||
DOCKED = "docked"
|
||||
IDLE = "idle"
|
||||
PAUSED = "paused"
|
||||
RETURNING = "returning"
|
||||
ERROR = "error"
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Implement the Yandex Smart Home base device capability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from functools import cached_property
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self, runtime_checkable
|
||||
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES
|
||||
from homeassistant.core import Context, HomeAssistant, State
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_SLOW
|
||||
from .helpers import CacheStore, ListRegistry
|
||||
from .schema import (
|
||||
CapabilityDescription,
|
||||
CapabilityInstance,
|
||||
CapabilityInstanceActionResultValue,
|
||||
CapabilityInstanceActionState,
|
||||
CapabilityInstanceState,
|
||||
CapabilityInstanceStateValue,
|
||||
CapabilityParameters,
|
||||
CapabilityType,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entry_data import ConfigEntryData
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Capability(Protocol[CapabilityInstanceActionState]):
|
||||
"""Base class for a device capability."""
|
||||
|
||||
device_id: str
|
||||
type: CapabilityType
|
||||
instance: CapabilityInstance
|
||||
|
||||
_hass: HomeAssistant
|
||||
_entry_data: ConfigEntryData
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
...
|
||||
|
||||
@property
|
||||
def retrievable(self) -> bool:
|
||||
"""Test if the capability can return the current value."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def reportable(self) -> bool:
|
||||
"""Test if the capability can report value changes."""
|
||||
return self._entry_data.is_reporting_states
|
||||
|
||||
@property
|
||||
def time_sensitive(self) -> bool:
|
||||
"""Test if value changes should be reported immediately."""
|
||||
return False
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def parameters(self) -> CapabilityParameters | None:
|
||||
"""Return parameters for a devices list request."""
|
||||
...
|
||||
|
||||
def get_description(self) -> CapabilityDescription | None:
|
||||
"""Return a description for a device list request. Capability with an empty description isn't discoverable."""
|
||||
return CapabilityDescription(
|
||||
type=self.type, retrievable=self.retrievable, reportable=self.reportable, parameters=self.parameters
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def get_value(self) -> Any:
|
||||
"""Return the current capability value."""
|
||||
...
|
||||
|
||||
def get_instance_state(self) -> CapabilityInstanceState | None:
|
||||
"""Return a state for a state query request."""
|
||||
if (value := self.get_value()) is not None:
|
||||
return CapabilityInstanceState(
|
||||
type=self.type, state=CapabilityInstanceStateValue(instance=self.instance, value=value)
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def set_instance_state(
|
||||
self, context: Context, state: CapabilityInstanceActionState
|
||||
) -> CapabilityInstanceActionResultValue:
|
||||
"""Change the capability state."""
|
||||
...
|
||||
|
||||
def check_value_change(self, other: Self | None) -> bool:
|
||||
"""Test if the capability value differs from other capability."""
|
||||
if other is None:
|
||||
return True
|
||||
|
||||
value, other_value = self.get_value(), other.get_value()
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if other_value is None or value != other_value:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def _entity_config(self) -> ConfigType:
|
||||
"""Return additional configuration for the device."""
|
||||
return self._entry_data.get_entity_config(self.device_id)
|
||||
|
||||
@property
|
||||
def _wait_for_service_call(self) -> bool:
|
||||
"""Check if service should be run in blocking mode."""
|
||||
if self._entity_config.get(CONF_SLOW) is True:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return string representation."""
|
||||
return f"instance {self.instance} of {self.type.short} capability of {self.device_id}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return the representation."""
|
||||
return (
|
||||
f"<{self.__class__.__name__}"
|
||||
f" device_id={self.device_id }"
|
||||
f" type={self.type}"
|
||||
f" instance={self.instance}"
|
||||
f">"
|
||||
)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
"""Compare capabilities."""
|
||||
return bool(
|
||||
isinstance(other, Capability)
|
||||
and self.type == other.type
|
||||
and self.instance == other.instance
|
||||
and self.device_id == other.device_id
|
||||
)
|
||||
|
||||
|
||||
class ActionOnlyCapabilityMixin:
|
||||
"""Represents a capability that can only execute an action."""
|
||||
|
||||
@property
|
||||
def retrievable(self) -> bool:
|
||||
"""Test if the capability can return the current value."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def reportable(self) -> bool:
|
||||
"""Test if the capability can report value changes."""
|
||||
return False
|
||||
|
||||
def get_value(self) -> None:
|
||||
"""Return the current capability value."""
|
||||
return None
|
||||
|
||||
|
||||
class DummyCapability(Capability[CapabilityInstanceActionState]):
|
||||
"""Represents a capability that user has disabled."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_data: ConfigEntryData,
|
||||
type: CapabilityType,
|
||||
instance: CapabilityInstance,
|
||||
device_id: str,
|
||||
):
|
||||
"""Initialize a dummy capability."""
|
||||
self._hass = hass
|
||||
self._entry_data = entry_data
|
||||
|
||||
self.type = type
|
||||
self.instance = instance
|
||||
self.device_id = device_id
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def parameters(self) -> CapabilityParameters | None:
|
||||
"""Return parameters for a devices list request."""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_value(self) -> Any:
|
||||
"""Return the current capability value."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def set_instance_state(
|
||||
self, context: Context, state: CapabilityInstanceActionState
|
||||
) -> CapabilityInstanceActionResultValue:
|
||||
"""Change the capability state."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StateCapability(Capability[CapabilityInstanceActionState], Protocol):
|
||||
"""Base class for a device capability based on the state."""
|
||||
|
||||
state: State
|
||||
device_id: str
|
||||
|
||||
_hass: HomeAssistant
|
||||
_entry_data: ConfigEntryData
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData, device_id: str, state: State):
|
||||
"""Initialize a capability for the state."""
|
||||
self._hass = hass
|
||||
self._entry_data = entry_data
|
||||
|
||||
self.device_id = device_id
|
||||
self.state = state
|
||||
|
||||
@property
|
||||
def _state_features(self) -> int:
|
||||
"""Return supported features for the state."""
|
||||
return int(self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0))
|
||||
|
||||
@property
|
||||
def _cache(self) -> CacheStore:
|
||||
"""Return cache storage."""
|
||||
return self._entry_data.cache
|
||||
|
||||
|
||||
STATE_CAPABILITIES_REGISTRY = ListRegistry[type[StateCapability[Any]]]()
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Implement the Yandex Smart Home color_setting capability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from functools import cached_property
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from homeassistant.components import light
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_EFFECT,
|
||||
ATTR_EFFECT_LIST,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_RGBW_COLOR,
|
||||
ATTR_RGBWW_COLOR,
|
||||
ATTR_WHITE,
|
||||
ColorMode,
|
||||
LightEntityFeature,
|
||||
color_temp_supported,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON
|
||||
from homeassistant.core import Context, HomeAssistant, State
|
||||
from homeassistant.util.color import RGBColor
|
||||
|
||||
from .capability import STATE_CAPABILITIES_REGISTRY, Capability, StateCapability
|
||||
from .color import SOLID_LIGHT_EFFECT, ColorConverter, ColorTemperatureConverter, LightState
|
||||
from .const import CONF_COLOR_PROFILE, CONF_ENTITY_CUSTOM_MODES, CONF_ENTITY_MODE_MAP
|
||||
from .helpers import APIError
|
||||
from .schema import (
|
||||
CapabilityInstance,
|
||||
CapabilityParameterColorModel,
|
||||
CapabilityParameterColorScene,
|
||||
CapabilityParameterTemperatureK,
|
||||
CapabilityType,
|
||||
ColorScene,
|
||||
ColorSettingCapabilityInstance,
|
||||
ColorSettingCapabilityInstanceActionState,
|
||||
ColorSettingCapabilityParameters,
|
||||
ResponseCode,
|
||||
RGBInstanceActionState,
|
||||
SceneInstanceActionState,
|
||||
TemperatureKInstanceActionState,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entry_data import ConfigEntryData
|
||||
|
||||
|
||||
class ColorSettingCapability(StateCapability[ColorSettingCapabilityInstanceActionState]):
|
||||
"""Capability to discover another color_setting capabilities.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/color_setting.html
|
||||
"""
|
||||
|
||||
type: CapabilityType = CapabilityType.COLOR_SETTING
|
||||
instance: CapabilityInstance = ColorSettingCapabilityInstance.BASE
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData, device_id: str, state: State):
|
||||
"""Initialize a capability for the state."""
|
||||
super().__init__(hass, entry_data, device_id, state)
|
||||
|
||||
self._color = RGBColorCapability(hass, entry_data, device_id, state)
|
||||
self._temperature = ColorTemperatureCapability(hass, entry_data, device_id, state)
|
||||
self._scene = self._get_scene_capability()
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
for capability in self._capabilities:
|
||||
if capability.supported:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def parameters(self) -> ColorSettingCapabilityParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return ColorSettingCapabilityParameters(
|
||||
color_model=self._color.parameters.color_model if self._color.supported else None,
|
||||
temperature_k=self._temperature.parameters.temperature_k if self._temperature.supported else None,
|
||||
color_scene=self._scene.parameters.color_scene if self._scene.supported else None,
|
||||
)
|
||||
|
||||
def get_value(self) -> None:
|
||||
"""Return the current capability value."""
|
||||
return None
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ColorSettingCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
raise APIError(ResponseCode.INTERNAL_ERROR, "No instance")
|
||||
|
||||
def _get_scene_capability(self) -> ColorSceneCapability:
|
||||
"""Return scene capability."""
|
||||
scene_instance = ColorSettingCapabilityInstance.SCENE
|
||||
if custom_scene_config := self._entity_config.get(CONF_ENTITY_CUSTOM_MODES, {}).get(scene_instance):
|
||||
from .capability_custom import get_custom_capability
|
||||
|
||||
custom_capability = get_custom_capability(
|
||||
self._hass, self._entry_data, custom_scene_config, CapabilityType.MODE, scene_instance, self.device_id
|
||||
)
|
||||
return cast(ColorSceneCapability, custom_capability)
|
||||
|
||||
return ColorSceneStateCapability(self._hass, self._entry_data, self.device_id, self.state)
|
||||
|
||||
@property
|
||||
def _capabilities(self) -> list[Capability[Any]]:
|
||||
"""Return all child capabilities."""
|
||||
return [self._color, self._temperature, self._scene]
|
||||
|
||||
|
||||
class RGBColorCapability(StateCapability[RGBInstanceActionState], LightState):
|
||||
"""Capability to control color of a light device."""
|
||||
|
||||
type = CapabilityType.COLOR_SETTING
|
||||
instance = ColorSettingCapabilityInstance.RGB
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if capability is supported."""
|
||||
return self.state.domain == light.DOMAIN and bool(
|
||||
{
|
||||
ColorMode.RGB,
|
||||
ColorMode.RGBW,
|
||||
ColorMode.RGBWW,
|
||||
ColorMode.HS,
|
||||
ColorMode.XY,
|
||||
}
|
||||
& self._supported_color_modes
|
||||
)
|
||||
|
||||
@property
|
||||
def parameters(self) -> ColorSettingCapabilityParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return ColorSettingCapabilityParameters(color_model=CapabilityParameterColorModel.RGB)
|
||||
|
||||
def get_description(self) -> None:
|
||||
"""Return a description for a device list request. Capability with an empty description isn't discoverable."""
|
||||
return None
|
||||
|
||||
def get_value(self) -> int | None:
|
||||
"""Return the current capability value."""
|
||||
if self.state.attributes.get(ATTR_COLOR_MODE) == ColorMode.COLOR_TEMP:
|
||||
return None
|
||||
|
||||
if self._rgb_color:
|
||||
if self._rgb_color in (RGBColor(255, 255, 255), RGBColor(0, 0, 0)):
|
||||
return None
|
||||
|
||||
return self._converter.get_yandex_color(self._rgb_color)
|
||||
|
||||
return None
|
||||
|
||||
async def set_instance_state(self, context: Context, state: RGBInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
color = self._converter.get_ha_color(state.value)
|
||||
service_data: dict[str, Any] = {ATTR_ENTITY_ID: self.state.entity_id}
|
||||
|
||||
if ColorMode.RGBWW in self._supported_color_modes:
|
||||
service_data[ATTR_RGBWW_COLOR] = tuple(color) + (
|
||||
self._white_brightness or 0,
|
||||
self._warm_white_brightness or 0,
|
||||
)
|
||||
elif ColorMode.RGBW in self._supported_color_modes:
|
||||
service_data[ATTR_RGBW_COLOR] = tuple(color) + (self._white_brightness or 0,)
|
||||
else:
|
||||
service_data[ATTR_RGB_COLOR] = tuple(color)
|
||||
|
||||
await self._hass.services.async_call(
|
||||
light.DOMAIN, SERVICE_TURN_ON, service_data, blocking=self._wait_for_service_call, context=context
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def _converter(self) -> ColorConverter:
|
||||
"""Return the color converter."""
|
||||
if color_profile_name := self._entity_config.get(CONF_COLOR_PROFILE):
|
||||
try:
|
||||
return ColorConverter(self._entry_data.color_profiles[color_profile_name])
|
||||
except KeyError:
|
||||
raise APIError(
|
||||
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
f"Color profile '{color_profile_name}' not found for {self}",
|
||||
)
|
||||
|
||||
return ColorConverter()
|
||||
|
||||
|
||||
class ColorTemperatureCapability(StateCapability[TemperatureKInstanceActionState], LightState):
|
||||
"""Capability to control color temperature of a light device."""
|
||||
|
||||
type = CapabilityType.COLOR_SETTING
|
||||
instance = ColorSettingCapabilityInstance.TEMPERATURE_K
|
||||
|
||||
_default_white_temperature = ColorTemperatureConverter.default_white_temperature
|
||||
_cold_white_temperature = 6500
|
||||
_color_modes_temp_to_white = {ColorMode.RGBW, ColorMode.RGB, ColorMode.HS, ColorMode.XY}
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if capability is supported."""
|
||||
if self.state.domain == light.DOMAIN:
|
||||
if color_temp_supported(self._supported_color_modes):
|
||||
return True
|
||||
|
||||
if self._color_modes_temp_to_white & self._supported_color_modes:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def parameters(self) -> ColorSettingCapabilityParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
if color_temp_supported(self._supported_color_modes):
|
||||
min_temp, max_temp = self._converter.supported_range
|
||||
return ColorSettingCapabilityParameters(
|
||||
temperature_k=CapabilityParameterTemperatureK(min=min_temp, max=max_temp)
|
||||
)
|
||||
|
||||
min_temp = self._default_white_temperature
|
||||
max_temp = self._default_white_temperature
|
||||
if {ColorMode.RGBW, ColorMode.WHITE} & self._supported_color_modes:
|
||||
max_temp = self._cold_white_temperature
|
||||
|
||||
return ColorSettingCapabilityParameters(
|
||||
temperature_k=CapabilityParameterTemperatureK(min=min_temp, max=max_temp)
|
||||
)
|
||||
|
||||
def get_description(self) -> None:
|
||||
"""Return a description for a device list request. Capability with an empty description isn't discoverable."""
|
||||
return None
|
||||
|
||||
def get_value(self) -> int | None:
|
||||
"""Return the current capability value."""
|
||||
color_temperature = self.state.attributes.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
if color_temperature is not None:
|
||||
return self._converter.get_yandex_color_temperature(int(color_temperature))
|
||||
|
||||
color_mode = self.state.attributes.get(ATTR_COLOR_MODE)
|
||||
match color_mode:
|
||||
case ColorMode.WHITE:
|
||||
return self._default_white_temperature
|
||||
|
||||
case ColorMode.RGBW:
|
||||
if self._rgb_color == RGBColor(0, 0, 0) and (self._white_brightness or 0) > 0:
|
||||
return self._default_white_temperature
|
||||
elif self._rgb_color == RGBColor(255, 255, 255):
|
||||
return self._cold_white_temperature
|
||||
|
||||
case _:
|
||||
if self._rgb_color == RGBColor(255, 255, 255):
|
||||
if ColorMode.WHITE in self._supported_color_modes:
|
||||
return self._cold_white_temperature
|
||||
|
||||
return self._default_white_temperature
|
||||
|
||||
return None
|
||||
|
||||
async def set_instance_state(self, context: Context, state: TemperatureKInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
service_data: dict[str, Any] = {ATTR_ENTITY_ID: self.state.entity_id}
|
||||
|
||||
if color_temp_supported(self._supported_color_modes):
|
||||
service_data[ATTR_COLOR_TEMP_KELVIN] = self._converter.get_ha_color_temperature(state.value)
|
||||
|
||||
elif ColorMode.WHITE in self._supported_color_modes and state.value == self._default_white_temperature:
|
||||
service_data[ATTR_WHITE] = self.state.attributes.get(ATTR_BRIGHTNESS, 255)
|
||||
|
||||
elif ColorMode.RGBW in self._supported_color_modes:
|
||||
if state.value == self._default_white_temperature:
|
||||
service_data[ATTR_RGBW_COLOR] = (0, 0, 0, self.state.attributes.get(ATTR_BRIGHTNESS, 255))
|
||||
if self._solid_effect_supported:
|
||||
service_data[ATTR_EFFECT] = SOLID_LIGHT_EFFECT
|
||||
else:
|
||||
service_data[ATTR_RGBW_COLOR] = (255, 255, 255, 0)
|
||||
|
||||
else:
|
||||
service_data[ATTR_RGB_COLOR] = (255, 255, 255)
|
||||
|
||||
await self._hass.services.async_call(
|
||||
light.DOMAIN, SERVICE_TURN_ON, service_data, blocking=self._wait_for_service_call, context=context
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def _converter(self) -> ColorTemperatureConverter:
|
||||
"""Return the color temperature converter."""
|
||||
if color_profile_name := self._entity_config.get(CONF_COLOR_PROFILE):
|
||||
try:
|
||||
return ColorTemperatureConverter(self._entry_data.color_profiles[color_profile_name], self.state)
|
||||
|
||||
except KeyError:
|
||||
raise APIError(
|
||||
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
f"Color profile '{color_profile_name}' not found for {self}",
|
||||
)
|
||||
|
||||
return ColorTemperatureConverter(None, self.state)
|
||||
|
||||
|
||||
class ColorSceneCapability(Capability[SceneInstanceActionState]):
|
||||
"""Base class for capability to control color scene."""
|
||||
|
||||
type: CapabilityType = CapabilityType.COLOR_SETTING
|
||||
instance: CapabilityInstance = ColorSettingCapabilityInstance.SCENE
|
||||
|
||||
_scenes_map_default: dict[ColorScene, list[str]] = {}
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return bool(self.supported_yandex_scenes)
|
||||
|
||||
@property
|
||||
def parameters(self) -> ColorSettingCapabilityParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return ColorSettingCapabilityParameters(
|
||||
color_scene=CapabilityParameterColorScene.from_list(self.supported_yandex_scenes)
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_yandex_scenes(self) -> list[ColorScene]:
|
||||
"""Returns a list of supported Yandex scenes."""
|
||||
scenes = set()
|
||||
for ha_value in self.supported_ha_scenes:
|
||||
if value := self.get_yandex_scene_by_ha_scene(ha_value):
|
||||
scenes.add(value)
|
||||
|
||||
return sorted(list(scenes))
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported_ha_scenes(self) -> list[str]:
|
||||
"""Returns a list of supported HA scenes."""
|
||||
...
|
||||
|
||||
@cached_property
|
||||
def scenes_map(self) -> dict[ColorScene, list[str]]:
|
||||
"""Return scene mapping between Yandex and HA."""
|
||||
scenes_map = self._scenes_map_default.copy()
|
||||
|
||||
if CONF_ENTITY_MODE_MAP in self._entity_config:
|
||||
scenes_map.update(
|
||||
{ColorScene(k): v for k, v in self._entity_config[CONF_ENTITY_MODE_MAP].get(self.instance, {}).items()}
|
||||
)
|
||||
|
||||
return scenes_map
|
||||
|
||||
def get_yandex_scene_by_ha_scene(self, ha_scene: str) -> ColorScene | None:
|
||||
"""Return Yandex scene for HA scene."""
|
||||
for scene, names in self.scenes_map.items():
|
||||
if ha_scene.lower() in [n.lower() for n in names]:
|
||||
return scene
|
||||
|
||||
return None
|
||||
|
||||
def get_ha_scene_by_yandex_scene(self, yandex_scene: ColorScene) -> str:
|
||||
"""Return HA scene for Yandex scene."""
|
||||
ha_scenes = self.scenes_map.get(yandex_scene, [])
|
||||
for ha_scene in ha_scenes:
|
||||
for sc in self.supported_ha_scenes:
|
||||
if sc.lower() == ha_scene.lower():
|
||||
return sc
|
||||
|
||||
raise APIError(
|
||||
ResponseCode.INVALID_VALUE,
|
||||
f"Unsupported scene '{yandex_scene}' for {self}, see https://docs.yaha-cloud.ru/v1.0.x/config/modes/",
|
||||
)
|
||||
|
||||
def get_description(self) -> None:
|
||||
"""Return a description for a device list request. Capability with an empty description isn't discoverable."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def get_value(self) -> ColorScene | None:
|
||||
"""Return the current capability value."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def set_instance_state(self, context: Context, state: SceneInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
...
|
||||
|
||||
|
||||
class ColorSceneStateCapability(ColorSceneCapability, StateCapability[SceneInstanceActionState]):
|
||||
"""Capability to control effect of a light device."""
|
||||
|
||||
_scenes_map_default = {
|
||||
ColorScene.ALARM: ["Тревога", "Alarm", "Shine", "Strobe Mega"],
|
||||
ColorScene.ALICE: ["Алиса", "Alice", "Meeting"],
|
||||
ColorScene.CANDLE: ["Свеча", "Огонь", "Candle", "Fire"],
|
||||
ColorScene.DINNER: ["Ужин", "Dinner"],
|
||||
ColorScene.FANTASY: ["Фантазия", "Fantasy", "Random", "Beautiful", "Sinelon Rainbow"],
|
||||
ColorScene.GARLAND: ["Гирлянда", "Garland", "Dynamic"],
|
||||
ColorScene.JUNGLE: ["Джунгли", "Jungle"],
|
||||
ColorScene.MOVIE: ["Кино", "Movie"],
|
||||
ColorScene.NEON: ["Неон", "Neon", "Breath"],
|
||||
ColorScene.NIGHT: ["Ночь", "Night", "Aurora"],
|
||||
ColorScene.OCEAN: ["Океан", "Ocean", "Pacifica"],
|
||||
ColorScene.PARTY: ["Вечеринка", "Party", "Juggle"],
|
||||
ColorScene.READING: ["Чтение", "Reading", "Read"],
|
||||
ColorScene.REST: ["Отдых", "Rest", "Soft"],
|
||||
ColorScene.ROMANCE: ["Романтика", "Romance", "Leasure", "Lake"],
|
||||
ColorScene.SIREN: ["Сирена", "Siren", "Police", "Rainbow"],
|
||||
ColorScene.SUNRISE: ["Рассвет", "Sunrise"],
|
||||
ColorScene.SUNSET: ["Закат", "Sunset"],
|
||||
}
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
if self.state.domain == light.DOMAIN and self._state_features & LightEntityFeature.EFFECT:
|
||||
return super().supported
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def supported_ha_scenes(self) -> list[str]:
|
||||
"""Returns a list of supported Yandex scenes."""
|
||||
return list(map(str, self.state.attributes.get(ATTR_EFFECT_LIST, []) or []))
|
||||
|
||||
def get_value(self) -> ColorScene | None:
|
||||
"""Return the current capability value."""
|
||||
if (effect := self.state.attributes.get(ATTR_EFFECT)) is not None:
|
||||
return self.get_yandex_scene_by_ha_scene(str(effect))
|
||||
|
||||
return None
|
||||
|
||||
async def set_instance_state(self, context: Context, state: SceneInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
await self._hass.services.async_call(
|
||||
light.DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
ATTR_EFFECT: self.get_ha_scene_by_yandex_scene(state.value),
|
||||
},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
STATE_CAPABILITIES_REGISTRY.register(ColorSettingCapability)
|
||||
STATE_CAPABILITIES_REGISTRY.register(RGBColorCapability)
|
||||
STATE_CAPABILITIES_REGISTRY.register(ColorTemperatureCapability)
|
||||
STATE_CAPABILITIES_REGISTRY.register(ColorSceneStateCapability)
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Implement the Yandex Smart Home user specific capabilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
import itertools
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Iterable, Protocol, Self, cast
|
||||
|
||||
from homeassistant.const import CONF_STATE_TEMPLATE, STATE_OFF, STATE_ON, STATE_UNKNOWN
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.service import async_call_from_config
|
||||
from homeassistant.helpers.template import Template, forgiving_boolean
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||
|
||||
from .capability import Capability
|
||||
from .capability_color import ColorSceneCapability
|
||||
from .capability_mode import ModeCapability
|
||||
from .capability_onoff import OnOffCapability, OnOffCapabilityInstanceActionState
|
||||
from .capability_range import RangeCapability
|
||||
from .capability_toggle import ToggleCapability
|
||||
from .const import (
|
||||
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE,
|
||||
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID,
|
||||
CONF_ENTITY_CUSTOM_MODE_SET_MODE,
|
||||
CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE,
|
||||
CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE,
|
||||
CONF_ENTITY_CUSTOM_RANGE_SET_VALUE,
|
||||
CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF,
|
||||
CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON,
|
||||
CONF_ENTITY_MODE_MAP,
|
||||
CONF_ENTITY_RANGE,
|
||||
CONF_ENTITY_RANGE_MAX,
|
||||
CONF_ENTITY_RANGE_MIN,
|
||||
CONF_ENTITY_RANGE_PRECISION,
|
||||
CONF_STATE_UNKNOWN,
|
||||
)
|
||||
from .helpers import ActionNotAllowed, APIError
|
||||
from .schema import (
|
||||
CapabilityInstance,
|
||||
CapabilityType,
|
||||
ColorScene,
|
||||
ColorSettingCapabilityInstance,
|
||||
ModeCapabilityInstance,
|
||||
ModeCapabilityInstanceActionState,
|
||||
ModeCapabilityMode,
|
||||
OnOffCapabilityInstance,
|
||||
RangeCapabilityInstance,
|
||||
RangeCapabilityInstanceActionState,
|
||||
RangeCapabilityRange,
|
||||
ResponseCode,
|
||||
SceneInstanceActionState,
|
||||
ToggleCapabilityInstance,
|
||||
ToggleCapabilityInstanceActionState,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entry_data import ConfigEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomCapability(Capability[Any], Protocol):
|
||||
"""Base class for a capability that user can set up using yaml configuration."""
|
||||
|
||||
device_id: str
|
||||
instance: CapabilityInstance
|
||||
|
||||
_hass: HomeAssistant
|
||||
_entry_data: ConfigEntryData
|
||||
_config: ConfigType
|
||||
_value_template: Template | None
|
||||
_value: Any | UndefinedType
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_data: ConfigEntryData,
|
||||
config: ConfigType,
|
||||
instance: CapabilityInstance,
|
||||
device_id: str,
|
||||
value_template: Template | None,
|
||||
value: Any | UndefinedType = UNDEFINED,
|
||||
):
|
||||
"""Initialize a custom capability."""
|
||||
self._hass = hass
|
||||
self._entry_data = entry_data
|
||||
self._config = config
|
||||
self._value_template = value_template
|
||||
self._value = value
|
||||
|
||||
self.device_id = device_id
|
||||
self.instance = instance
|
||||
|
||||
# noinspection PyProtocol
|
||||
@property
|
||||
def retrievable(self) -> bool:
|
||||
"""Test if the capability can return the current value."""
|
||||
return self._value_template is not None
|
||||
|
||||
@property
|
||||
def reportable(self) -> bool:
|
||||
"""Test if the capability can report value changes."""
|
||||
if not self.retrievable:
|
||||
return False
|
||||
|
||||
return super().reportable
|
||||
|
||||
def new_with_value(self, value: Any) -> Self:
|
||||
"""Return copy of the state with new value."""
|
||||
return self.__class__(
|
||||
self._hass,
|
||||
self._entry_data,
|
||||
self._config,
|
||||
self.instance,
|
||||
self.device_id,
|
||||
self._value_template,
|
||||
value,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _get_source_value(self) -> Any:
|
||||
"""Return the current capability value (unprocessed)."""
|
||||
if self._value_template is None:
|
||||
return None
|
||||
|
||||
if self._value is not UNDEFINED:
|
||||
return self._value
|
||||
|
||||
try:
|
||||
return self._value_template.async_render()
|
||||
except TemplateError as exc:
|
||||
raise APIError(ResponseCode.INVALID_VALUE, f"Failed to get current value for {self}: {exc!r}")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return the representation."""
|
||||
return (
|
||||
f"<{self.__class__.__name__}"
|
||||
f" device_id={self.device_id }"
|
||||
f" instance={self.instance}"
|
||||
f" value_template={self._value_template}"
|
||||
f" value={self._value}"
|
||||
f">"
|
||||
)
|
||||
|
||||
|
||||
class CustomOnOffCapability(CustomCapability, OnOffCapability):
|
||||
"""OnOff capability that user can set up using yaml configuration."""
|
||||
|
||||
instance: OnOffCapabilityInstance
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def retrievable(self) -> bool:
|
||||
"""Test if the capability can return the current value."""
|
||||
if self._entity_config.get(CONF_STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_value(self) -> bool | None:
|
||||
"""Return the current capability value."""
|
||||
if not self.retrievable:
|
||||
return None
|
||||
|
||||
if self._value_template is not None:
|
||||
return bool(self._get_source_value() == STATE_ON)
|
||||
|
||||
return False
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state (if wasn't overriden by the user)."""
|
||||
raise ActionNotAllowed
|
||||
|
||||
|
||||
class CustomModeCapability(CustomCapability, ModeCapability):
|
||||
"""Mode capability that user can set up using yaml configuration."""
|
||||
|
||||
instance: ModeCapabilityInstance
|
||||
|
||||
def get_value(self) -> ModeCapabilityMode | None:
|
||||
"""Return the current capability value."""
|
||||
if not self.retrievable:
|
||||
return None
|
||||
|
||||
if (value := self._get_source_value()) is not None:
|
||||
return self.get_yandex_mode_by_ha_mode(str(value))
|
||||
|
||||
return None
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
service_config = self._config.get(CONF_ENTITY_CUSTOM_MODE_SET_MODE)
|
||||
if not service_config:
|
||||
raise ActionNotAllowed
|
||||
|
||||
await async_call_from_config(
|
||||
self._hass,
|
||||
service_config,
|
||||
validate_config=False,
|
||||
variables={"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."""
|
||||
modes = self._entity_config.get(CONF_ENTITY_MODE_MAP, {}).get(self.instance, {})
|
||||
return itertools.chain(*modes.values())
|
||||
|
||||
|
||||
class CustomToggleCapability(CustomCapability, ToggleCapability):
|
||||
"""Toggle capability that user can set up using yaml configuration."""
|
||||
|
||||
instance: ToggleCapabilityInstance
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return True
|
||||
|
||||
def get_value(self) -> bool | None:
|
||||
"""Return the current capability value."""
|
||||
if not self.retrievable:
|
||||
return None
|
||||
|
||||
value = self._get_source_value()
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return forgiving_boolean(value, None)
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
if state.value:
|
||||
service_config = self._config.get(CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON)
|
||||
else:
|
||||
service_config = self._config.get(CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF)
|
||||
|
||||
if not service_config:
|
||||
raise ActionNotAllowed
|
||||
|
||||
await async_call_from_config(
|
||||
self._hass,
|
||||
service_config,
|
||||
validate_config=False,
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class CustomRangeCapability(CustomCapability, RangeCapability):
|
||||
"""Range capability that user can set up using yaml configuration."""
|
||||
|
||||
instance: RangeCapabilityInstance
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def support_random_access(self) -> bool:
|
||||
"""Test if the capability accept arbitrary values to be set."""
|
||||
for key in [CONF_ENTITY_RANGE_MIN, CONF_ENTITY_RANGE_MAX]:
|
||||
if key not in self._config.get(CONF_ENTITY_RANGE, {}):
|
||||
return False
|
||||
|
||||
return self._set_value_service_config is not None
|
||||
|
||||
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
service_config = self._set_value_service_config
|
||||
value = state.value
|
||||
|
||||
if state.relative:
|
||||
if self._increase_value_service_config or self._decrease_value_service_config:
|
||||
if state.value > 0:
|
||||
service_config = self._increase_value_service_config
|
||||
else:
|
||||
service_config = self._decrease_value_service_config
|
||||
else:
|
||||
if not self.retrievable:
|
||||
raise APIError(
|
||||
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
f"Unable to set relative value for {self}: no current value source or service found",
|
||||
)
|
||||
|
||||
value = self._get_absolute_value(state.value)
|
||||
|
||||
if not service_config:
|
||||
raise ActionNotAllowed
|
||||
|
||||
await async_call_from_config(
|
||||
self._hass,
|
||||
service_config,
|
||||
validate_config=False,
|
||||
variables={"value": value},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
def _get_value(self) -> float | None:
|
||||
"""Return the current capability value (unguarded)."""
|
||||
if not self.retrievable:
|
||||
return None
|
||||
|
||||
return self._convert_to_float(self._get_source_value())
|
||||
|
||||
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._value_template is not None:
|
||||
info = self._value_template.async_render_to_info()
|
||||
for entity_id in info.entities:
|
||||
state = self._hass.states.get(entity_id)
|
||||
if state is None:
|
||||
raise APIError(ResponseCode.DEVICE_OFF, f"Entity {entity_id} not found")
|
||||
elif state.state in (STATE_OFF, STATE_UNKNOWN):
|
||||
raise APIError(ResponseCode.DEVICE_OFF, f"Device {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)
|
||||
|
||||
@cached_property
|
||||
def _range(self) -> RangeCapabilityRange:
|
||||
"""Return supporting value range."""
|
||||
return RangeCapabilityRange(
|
||||
min=self._config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MIN, super()._range.min),
|
||||
max=self._config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MAX, super()._range.max),
|
||||
precision=self._config.get(CONF_ENTITY_RANGE, {}).get(
|
||||
CONF_ENTITY_RANGE_PRECISION, super()._range.precision
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def _set_value_service_config(self) -> ConfigType | None:
|
||||
"""Return service configuration for setting value action."""
|
||||
return self._config.get(CONF_ENTITY_CUSTOM_RANGE_SET_VALUE)
|
||||
|
||||
@property
|
||||
def _increase_value_service_config(self) -> ConfigType | None:
|
||||
"""Return service configuration for setting increase value action."""
|
||||
return self._config.get(CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE)
|
||||
|
||||
@property
|
||||
def _decrease_value_service_config(self) -> ConfigType | None:
|
||||
"""Return service configuration for setting decrease value action."""
|
||||
return self._config.get(CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE)
|
||||
|
||||
|
||||
class CustomColorSceneCapability(CustomCapability, ColorSceneCapability):
|
||||
"""Custom scene capability that user can set up using yaml configuration."""
|
||||
|
||||
@property
|
||||
def supported_ha_scenes(self) -> list[str]:
|
||||
"""Returns a list of supported HA scenes."""
|
||||
modes = self._entity_config.get(CONF_ENTITY_MODE_MAP, {}).get(self.instance, {})
|
||||
return list(itertools.chain(*modes.values()))
|
||||
|
||||
def get_value(self) -> ColorScene | None:
|
||||
"""Return the current capability value."""
|
||||
if not self.retrievable:
|
||||
return None
|
||||
|
||||
if (value := self._get_source_value()) is not None:
|
||||
return self.get_yandex_scene_by_ha_scene(str(value))
|
||||
|
||||
return None
|
||||
|
||||
async def set_instance_state(self, context: Context, state: SceneInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
service_config = self._config.get(CONF_ENTITY_CUSTOM_MODE_SET_MODE)
|
||||
if not service_config:
|
||||
raise ActionNotAllowed
|
||||
|
||||
await async_call_from_config(
|
||||
self._hass,
|
||||
service_config,
|
||||
validate_config=False,
|
||||
variables={"mode": self.get_ha_scene_by_yandex_scene(state.value)},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
def get_custom_capability(
|
||||
hass: HomeAssistant,
|
||||
entry_data: ConfigEntryData,
|
||||
capability_config: ConfigType,
|
||||
capability_type: CapabilityType,
|
||||
instance: str,
|
||||
device_id: str,
|
||||
) -> CustomCapability:
|
||||
"""Return initialized custom capability based on parameters."""
|
||||
value_template = get_value_template(hass, device_id, capability_config)
|
||||
|
||||
match capability_type:
|
||||
case CapabilityType.ON_OFF:
|
||||
return CustomOnOffCapability(
|
||||
hass, entry_data, capability_config, OnOffCapabilityInstance(instance), device_id, value_template
|
||||
)
|
||||
|
||||
case CapabilityType.MODE:
|
||||
if instance == ColorSettingCapabilityInstance.SCENE:
|
||||
return CustomColorSceneCapability(
|
||||
hass, entry_data, capability_config, ColorSettingCapabilityInstance.SCENE, device_id, value_template
|
||||
)
|
||||
|
||||
return CustomModeCapability(
|
||||
hass, entry_data, capability_config, ModeCapabilityInstance(instance), device_id, value_template
|
||||
)
|
||||
case CapabilityType.TOGGLE:
|
||||
return CustomToggleCapability(
|
||||
hass, entry_data, capability_config, ToggleCapabilityInstance(instance), device_id, value_template
|
||||
)
|
||||
case CapabilityType.RANGE:
|
||||
return CustomRangeCapability(
|
||||
hass, entry_data, capability_config, RangeCapabilityInstance(instance), device_id, value_template
|
||||
)
|
||||
|
||||
raise APIError(ResponseCode.INTERNAL_ERROR, f"Unsupported capability type: {capability_type}")
|
||||
|
||||
|
||||
def get_value_template(hass: HomeAssistant, device_id: str, capability_config: ConfigType) -> Template | None:
|
||||
"""Return capability value template from capability configuration."""
|
||||
if template := capability_config.get(CONF_STATE_TEMPLATE):
|
||||
return cast(Template, template)
|
||||
|
||||
entity_id = capability_config.get(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID)
|
||||
attribute = capability_config.get(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE)
|
||||
|
||||
if attribute:
|
||||
return Template("{{ state_attr('%s', '%s') }}" % (entity_id or device_id, attribute), hass)
|
||||
elif entity_id:
|
||||
return Template("{{ states('%s') }}" % entity_id, hass)
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,971 @@
|
||||
"""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):
|
||||
"""Generic HA mode for various devices."""
|
||||
|
||||
GENTLE = "gentle" # tuya vacuum?
|
||||
MAX_PLUS_SIGN = "max+" # deebot?
|
||||
HIGHEST = "highest" # smartir
|
||||
|
||||
|
||||
class SmartThinQFanMode(StrEnum):
|
||||
"""Fan mode for ollo69/ha-smartthinq-sensors integration."""
|
||||
|
||||
LOW_MID = "low_mid"
|
||||
MID_HIGH = "mid_high"
|
||||
|
||||
|
||||
class RoborockCleanupMode(StrEnum):
|
||||
"""Cleanup mode for humbertogontijo/python-roborock library.
|
||||
|
||||
https://github.com/humbertogontijo/python-roborock/blob/1616217a06e20d51921de984134555bcc0775a92/roborock/code_mappings.py#L61
|
||||
"""
|
||||
|
||||
OFF = "off"
|
||||
SILENT = "silent"
|
||||
BALANCED = "balanced"
|
||||
TURBO = "turbo"
|
||||
MAX = "max"
|
||||
MAX_PLUS = "max_plus"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class RoombaCleanupMode(StrEnum):
|
||||
"""Cleanup mode for roomba integration."""
|
||||
|
||||
AUTOMATIC = "Automatic"
|
||||
ECO = "Eco"
|
||||
PERFORMANCE = "Performance"
|
||||
STANDARD = "Standard"
|
||||
|
||||
|
||||
class TionFanSpeed(StrEnum):
|
||||
"""Fan speed for airens/tion_home_assistant integration.
|
||||
|
||||
https://github.com/airens/tion_home_assistant#climateset_fan_mode
|
||||
"""
|
||||
|
||||
S1 = "1"
|
||||
S2 = "2"
|
||||
S3 = "3"
|
||||
S4 = "4"
|
||||
S5 = "5"
|
||||
S6 = "6"
|
||||
|
||||
|
||||
class XiaomiHumidifierMode(StrEnum):
|
||||
"""Humidifer mode for xiaomi_miio integration."""
|
||||
|
||||
MID = "mid"
|
||||
|
||||
|
||||
class XiaomiMiotHumidifierMode(StrEnum):
|
||||
"""Humidifer mode for al-one/hass-xiaomi-miot integration."""
|
||||
|
||||
CONST_HUMIDITY = "Const Humidity" # leshow.humidifier.jsq1
|
||||
|
||||
|
||||
class XiaomiFanMode(StrEnum):
|
||||
"""Fan mode for xiaomi_miio integration."""
|
||||
|
||||
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):
|
||||
"""Fan mode for al-one/hass-xiaomi-miot and syssi/xiaomi_airpurifier integrations.
|
||||
|
||||
https://github.com/syssi/xiaomi_airpurifier#service-fanset_preset_mode
|
||||
https://github.com/al-one/hass-xiaomi-miot/blob/fdca601c409f619b1c98a20e6ea990317cce20c7/custom_components/xiaomi_miot/core/templates.py#L102
|
||||
"""
|
||||
|
||||
LEVEL_1 = "Level 1"
|
||||
LEVEL_2 = "Level 2"
|
||||
LEVEL_3 = "Level 3"
|
||||
LEVEL_4 = "Level 4"
|
||||
LEVEL_5 = "Level 5"
|
||||
|
||||
|
||||
class XiaomiMiotCleanupMode(StrEnum):
|
||||
"""Cleanup mode for al-one/hass-xiaomi-miot integration.
|
||||
|
||||
https://github.com/al-one/hass-xiaomi-miot/blob/fdca601c409f619b1c98a20e6ea990317cce20c7/custom_components/xiaomi_miot/core/miot_specs_extend.json#L641
|
||||
"""
|
||||
|
||||
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.
|
||||
|
||||
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/mode-docpage/
|
||||
"""
|
||||
|
||||
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 list(map(str, 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."""
|
||||
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
|
||||
|
||||
if mode is not None and ha_mode not in self.supported_ha_modes:
|
||||
raise APIError(
|
||||
ResponseCode.INVALID_VALUE,
|
||||
f"Unsupported HA mode '{ha_mode}' for {self}: not in {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:
|
||||
mode = self._modes_map_index_fallback[self.supported_ha_modes.index(ha_mode)]
|
||||
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 [m.lower() for m in self.supported_ha_modes]:
|
||||
_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:
|
||||
"""Test if the capability is supported."""
|
||||
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:
|
||||
"""Change the capability state."""
|
||||
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]:
|
||||
"""Returns list of HA modes."""
|
||||
return self.state.attributes.get(climate.ATTR_SWING_MODES, []) or []
|
||||
|
||||
@property
|
||||
def _ha_value(self) -> Any:
|
||||
"""Return the current unmapped capability value."""
|
||||
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:
|
||||
"""Test if the capability is supported."""
|
||||
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:
|
||||
"""Change the capability state."""
|
||||
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]:
|
||||
"""Returns list of HA modes."""
|
||||
return self.state.attributes.get(climate.ATTR_PRESET_MODES, []) or []
|
||||
|
||||
@property
|
||||
def _ha_value(self) -> Any:
|
||||
"""Return the current unmapped capability value."""
|
||||
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:
|
||||
"""Test if the capability is supported."""
|
||||
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:
|
||||
"""Change the capability state."""
|
||||
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]:
|
||||
"""Returns list of HA modes."""
|
||||
return self.state.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []) or []
|
||||
|
||||
@property
|
||||
def _ha_value(self) -> Any:
|
||||
"""Return the current unmapped capability value."""
|
||||
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:
|
||||
"""Test if the capability is supported."""
|
||||
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 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(
|
||||
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]:
|
||||
"""Returns list of HA modes."""
|
||||
return self.state.attributes.get(fan.ATTR_PRESET_MODES, []) or []
|
||||
|
||||
@property
|
||||
def _ha_value(self) -> Any:
|
||||
"""Return the current unmapped capability value."""
|
||||
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:
|
||||
"""Test if the capability is supported."""
|
||||
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 Yandex mode for HA mode."""
|
||||
return super().get_yandex_mode_by_ha_mode(ha_mode, hide_warnings=True)
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ModeCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
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]:
|
||||
"""Returns list of HA modes."""
|
||||
modes = self.state.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST, []) or []
|
||||
filtered_modes = list(filter(lambda m: m not in ["Live TV"], modes)) # #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 the current unmapped capability value."""
|
||||
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,
|
||||
],
|
||||
ModeCapabilityMode.QUIET: [
|
||||
climate.FAN_OFF,
|
||||
climate.FAN_DIFFUSE,
|
||||
],
|
||||
ModeCapabilityMode.MIN: [
|
||||
TionFanSpeed.S1,
|
||||
SmartThinQFanMode.LOW_MID,
|
||||
],
|
||||
ModeCapabilityMode.LOW: [
|
||||
climate.FAN_LOW,
|
||||
TionFanSpeed.S2,
|
||||
],
|
||||
ModeCapabilityMode.MEDIUM: [
|
||||
climate.FAN_MEDIUM,
|
||||
climate.FAN_MIDDLE,
|
||||
XiaomiHumidifierMode.MID,
|
||||
TionFanSpeed.S3,
|
||||
],
|
||||
ModeCapabilityMode.HIGH: [
|
||||
climate.FAN_HIGH,
|
||||
TionFanSpeed.S4,
|
||||
],
|
||||
ModeCapabilityMode.TURBO: [
|
||||
climate.FAN_FOCUS,
|
||||
GenericMode.HIGHEST,
|
||||
TionFanSpeed.S5,
|
||||
],
|
||||
ModeCapabilityMode.MAX: [
|
||||
TionFanSpeed.S6,
|
||||
SmartThinQFanMode.MID_HIGH,
|
||||
],
|
||||
}
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
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:
|
||||
"""Change the capability state."""
|
||||
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]:
|
||||
"""Returns list of HA modes."""
|
||||
modes = self.state.attributes.get(climate.ATTR_FAN_MODES, []) or []
|
||||
|
||||
# esphome default state for some devices
|
||||
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 the current unmapped capability value."""
|
||||
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:
|
||||
"""Test if the capability is supported."""
|
||||
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:
|
||||
"""Change the capability state."""
|
||||
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]:
|
||||
"""Returns list of HA modes."""
|
||||
return self.state.attributes.get(fan.ATTR_PRESET_MODES, []) or []
|
||||
|
||||
@property
|
||||
def _ha_value(self) -> Any:
|
||||
"""Return the current unmapped capability value."""
|
||||
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:
|
||||
"""Test if the capability is supported."""
|
||||
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]:
|
||||
"""Returns a list of supported Yandex modes."""
|
||||
return [ModeCapabilityMode(m) for m in self.supported_ha_modes]
|
||||
|
||||
def get_value(self) -> ModeCapabilityMode | None:
|
||||
"""Return the current capability value."""
|
||||
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:
|
||||
"""Change the capability state."""
|
||||
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]:
|
||||
"""Returns list of HA modes."""
|
||||
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 the current unmapped capability value."""
|
||||
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:
|
||||
"""Test if the capability is supported."""
|
||||
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:
|
||||
"""Change the capability state."""
|
||||
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]:
|
||||
"""Returns list of HA modes."""
|
||||
return self.state.attributes.get(vacuum.ATTR_FAN_SPEED_LIST, []) or []
|
||||
|
||||
@property
|
||||
def _ha_value(self) -> Any:
|
||||
"""Return the current unmapped capability value."""
|
||||
return self.state.attributes.get(vacuum.ATTR_FAN_SPEED)
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,569 @@
|
||||
"""Implement the Yandex Smart Home on_off capability."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Protocol
|
||||
|
||||
from homeassistant.components import (
|
||||
automation,
|
||||
button,
|
||||
climate,
|
||||
cover,
|
||||
fan,
|
||||
group,
|
||||
humidifier,
|
||||
input_boolean,
|
||||
input_button,
|
||||
light,
|
||||
lock,
|
||||
media_player,
|
||||
remote,
|
||||
scene,
|
||||
script,
|
||||
switch,
|
||||
vacuum,
|
||||
valve,
|
||||
water_heater,
|
||||
)
|
||||
from homeassistant.components.climate import HVACMode
|
||||
from homeassistant.components.media_player import MediaPlayerEntityFeature
|
||||
from homeassistant.components.vacuum import VacuumEntityFeature
|
||||
from homeassistant.components.water_heater import WaterHeaterEntityFeature
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_CLOSE_COVER,
|
||||
SERVICE_CLOSE_VALVE,
|
||||
SERVICE_LOCK,
|
||||
SERVICE_OPEN_COVER,
|
||||
SERVICE_OPEN_VALVE,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
SERVICE_UNLOCK,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_OPEN,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HA_DOMAIN, Context
|
||||
from homeassistant.helpers.service import async_call_from_config
|
||||
|
||||
from .backports import LockState, VacuumActivity
|
||||
from .capability import STATE_CAPABILITIES_REGISTRY, ActionOnlyCapabilityMixin, StateCapability
|
||||
from .const import (
|
||||
CONF_FEATURES,
|
||||
CONF_STATE_UNKNOWN,
|
||||
CONF_TURN_OFF,
|
||||
CONF_TURN_ON,
|
||||
SKYKETTLE_MODE_BOIL,
|
||||
MediaPlayerFeature,
|
||||
)
|
||||
from .helpers import ActionNotAllowed, APIError
|
||||
from .schema import (
|
||||
CapabilityType,
|
||||
OnOffCapabilityInstance,
|
||||
OnOffCapabilityInstanceActionState,
|
||||
OnOffCapabilityParameters,
|
||||
ResponseCode,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapability(StateCapability[OnOffCapabilityInstanceActionState], Protocol):
|
||||
"""Base class for capabilitity to turn on and off a device.
|
||||
|
||||
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/on_off-docpage/
|
||||
"""
|
||||
|
||||
type: CapabilityType = CapabilityType.ON_OFF
|
||||
instance: OnOffCapabilityInstance = OnOffCapabilityInstance.ON
|
||||
|
||||
@abstractmethod
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state (if wasn't overriden by the user)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def retrievable(self) -> bool:
|
||||
"""Test if the capability can return the current value."""
|
||||
if self._entity_config.get(CONF_STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def parameters(self) -> OnOffCapabilityParameters | None:
|
||||
"""Return parameters for a devices list request."""
|
||||
if not self.retrievable:
|
||||
return OnOffCapabilityParameters(split=True)
|
||||
|
||||
return None
|
||||
|
||||
def get_value(self) -> bool | None:
|
||||
"""Return the current capability value."""
|
||||
return self.state.state != STATE_OFF
|
||||
|
||||
async def set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
for key, call in ((CONF_TURN_ON, state.value), (CONF_TURN_OFF, not state.value)):
|
||||
if key in self._entity_config and call:
|
||||
if self._entity_config[key] is False:
|
||||
raise ActionNotAllowed
|
||||
|
||||
await async_call_from_config(
|
||||
self._hass, self._entity_config[key], blocking=self._wait_for_service_call, context=context
|
||||
)
|
||||
return
|
||||
|
||||
await self._set_instance_state(context, state)
|
||||
|
||||
@staticmethod
|
||||
def _get_service(state: OnOffCapabilityInstanceActionState) -> str:
|
||||
"""Return the service to be called for a new state."""
|
||||
if state.value:
|
||||
return SERVICE_TURN_ON
|
||||
|
||||
return SERVICE_TURN_OFF
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return string representation."""
|
||||
return f"{self.type.short} capability of {self.device_id}"
|
||||
|
||||
|
||||
class OnlyOnCapability(ActionOnlyCapabilityMixin, OnOffCapability, ABC):
|
||||
"""Capability to only turn on a device."""
|
||||
|
||||
@property
|
||||
def parameters(self) -> OnOffCapabilityParameters | None:
|
||||
"""Return parameters for a devices list request."""
|
||||
return None
|
||||
|
||||
|
||||
class OnOffCapabilityBasic(OnOffCapability):
|
||||
"""Capability to turn on or off a device."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain in (light.DOMAIN, fan.DOMAIN, switch.DOMAIN, humidifier.DOMAIN, input_boolean.DOMAIN)
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state (if wasn't overriden by the user)."""
|
||||
await self._hass.services.async_call(
|
||||
self.state.domain,
|
||||
self._get_service(state),
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityAutomation(OnOffCapability):
|
||||
"""Capability to enable or disable an automation."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return bool(self.state.domain == automation.DOMAIN)
|
||||
|
||||
def get_value(self) -> bool | None:
|
||||
"""Return the current capability value."""
|
||||
return self.state.state == STATE_ON
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state (if wasn't overriden by the user)."""
|
||||
await self._hass.services.async_call(
|
||||
automation.DOMAIN,
|
||||
self._get_service(state),
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityGroup(OnOffCapability):
|
||||
"""Capability to turn on or off a group of devices."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain in group.DOMAIN
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state (if wasn't overriden by the user)."""
|
||||
await self._hass.services.async_call(
|
||||
HA_DOMAIN,
|
||||
self._get_service(state),
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityScript(OnlyOnCapability):
|
||||
"""Capability to call a script or scene."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain in (scene.DOMAIN, script.DOMAIN)
|
||||
|
||||
@property
|
||||
def _wait_for_service_call(self) -> bool:
|
||||
"""Check if service should be run in blocking mode."""
|
||||
if self.state.domain == script.DOMAIN:
|
||||
return False
|
||||
|
||||
return super()._wait_for_service_call
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
await self._hass.services.async_call(
|
||||
self.state.domain,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityButton(OnlyOnCapability):
|
||||
"""Capability to press a button."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == button.DOMAIN
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
await self._hass.services.async_call(
|
||||
self.state.domain,
|
||||
button.SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityInputButton(OnlyOnCapability):
|
||||
"""Capability to press a input_button."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == input_button.DOMAIN
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
await self._hass.services.async_call(
|
||||
self.state.domain,
|
||||
input_button.SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityLock(OnOffCapability):
|
||||
"""Capability to lock or unlock a lock."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == lock.DOMAIN
|
||||
|
||||
def get_value(self) -> bool | None:
|
||||
"""Return the current capability value."""
|
||||
return bool(self.state.state == LockState.UNLOCKED)
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
if state.value:
|
||||
service = SERVICE_UNLOCK
|
||||
else:
|
||||
service = SERVICE_LOCK
|
||||
|
||||
await self._hass.services.async_call(
|
||||
lock.DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityCover(OnOffCapability):
|
||||
"""Capability to open or close a cover."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == cover.DOMAIN
|
||||
|
||||
def get_value(self) -> bool | None:
|
||||
"""Return the current capability value."""
|
||||
return self.state.state == STATE_OPEN
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
if state.value:
|
||||
service = SERVICE_OPEN_COVER
|
||||
else:
|
||||
service = SERVICE_CLOSE_COVER
|
||||
|
||||
await self._hass.services.async_call(
|
||||
cover.DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityRemote(ActionOnlyCapabilityMixin, OnOffCapability):
|
||||
"""Capability to turn on or off a remote."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == remote.DOMAIN
|
||||
|
||||
@property
|
||||
def parameters(self) -> OnOffCapabilityParameters | None:
|
||||
"""Return parameters for a devices list request."""
|
||||
return OnOffCapabilityParameters(split=True)
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
await self._hass.services.async_call(
|
||||
remote.DOMAIN,
|
||||
self._get_service(state),
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=False,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityMediaPlayer(OnOffCapability):
|
||||
"""Capability to turn on or off a media player device."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
if self.state.domain == media_player.DOMAIN:
|
||||
if CONF_TURN_ON in self._entity_config or CONF_TURN_OFF in self._entity_config:
|
||||
return True
|
||||
|
||||
if MediaPlayerFeature.TURN_ON_OFF in self._entity_config.get(CONF_FEATURES, []):
|
||||
return True
|
||||
|
||||
return bool(
|
||||
self._state_features & MediaPlayerEntityFeature.TURN_ON
|
||||
or self._state_features & MediaPlayerEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state (if wasn't overriden by the user)."""
|
||||
await self._hass.services.async_call(
|
||||
media_player.DOMAIN,
|
||||
self._get_service(state),
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityVacuum(OnOffCapability):
|
||||
"""Capability to start or stop cleaning by a vacuum."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
if self.state.domain != vacuum.DOMAIN:
|
||||
return False
|
||||
|
||||
if CONF_TURN_ON in self._entity_config:
|
||||
return True
|
||||
|
||||
if self._state_features & VacuumEntityFeature.TURN_ON and self._state_features & VacuumEntityFeature.TURN_OFF:
|
||||
return True
|
||||
|
||||
if self._state_features & VacuumEntityFeature.START:
|
||||
if (
|
||||
self._state_features & VacuumEntityFeature.RETURN_HOME
|
||||
or self._state_features & VacuumEntityFeature.STOP
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_value(self) -> bool | None:
|
||||
"""Return the current capability value."""
|
||||
return self.state.state in [STATE_ON, VacuumActivity.CLEANING]
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state (if wasn't overriden by the user)."""
|
||||
if state.value:
|
||||
service = SERVICE_TURN_ON
|
||||
|
||||
if self._state_features & VacuumEntityFeature.START:
|
||||
service = vacuum.SERVICE_START
|
||||
else:
|
||||
service = SERVICE_TURN_OFF
|
||||
|
||||
if self._state_features & VacuumEntityFeature.RETURN_HOME:
|
||||
service = vacuum.SERVICE_RETURN_TO_BASE
|
||||
elif self._state_features & VacuumEntityFeature.STOP:
|
||||
service = vacuum.SERVICE_STOP
|
||||
|
||||
await self._hass.services.async_call(
|
||||
vacuum.DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityClimate(OnOffCapability):
|
||||
"""Capability to turn on or off a climate device."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == climate.DOMAIN
|
||||
|
||||
def get_value(self) -> bool | None:
|
||||
"""Return the current capability value."""
|
||||
return self.state.state != HVACMode.OFF
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state (if wasn't overriden by the user)."""
|
||||
service_data = {ATTR_ENTITY_ID: self.state.entity_id}
|
||||
|
||||
if state.value:
|
||||
service = SERVICE_TURN_ON
|
||||
|
||||
hvac_modes = self.state.attributes.get(climate.ATTR_HVAC_MODES, [])
|
||||
for mode in (HVACMode.HEAT_COOL, HVACMode.AUTO):
|
||||
if mode not in hvac_modes:
|
||||
continue
|
||||
|
||||
service_data[climate.ATTR_HVAC_MODE] = mode
|
||||
service = climate.SERVICE_SET_HVAC_MODE
|
||||
break
|
||||
else:
|
||||
service = SERVICE_TURN_OFF
|
||||
|
||||
await self._hass.services.async_call(
|
||||
climate.DOMAIN, service, service_data, blocking=self._wait_for_service_call, context=context
|
||||
)
|
||||
|
||||
|
||||
class OnOffCapabilityWaterHeater(OnOffCapability):
|
||||
"""Capability to turn on or off a water heater."""
|
||||
|
||||
_water_heater_operations = {
|
||||
STATE_ON: [STATE_ON, "On", "ON", water_heater.STATE_ELECTRIC, SKYKETTLE_MODE_BOIL],
|
||||
STATE_OFF: [STATE_OFF, "Off", "OFF"],
|
||||
}
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == water_heater.DOMAIN
|
||||
|
||||
def get_value(self) -> bool | None:
|
||||
"""Return the current capability value."""
|
||||
return self.state.state.lower() != STATE_OFF
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state (if wasn't overriden by the user)."""
|
||||
if self._state_features & WaterHeaterEntityFeature.ON_OFF:
|
||||
await self._set_state_on_off(context, state)
|
||||
else:
|
||||
await self._set_state_operation_mode(context, state)
|
||||
|
||||
async def _set_state_on_off(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
await self._hass.services.async_call(
|
||||
water_heater.DOMAIN,
|
||||
self._get_service(state),
|
||||
{
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
async def _set_state_operation_mode(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
operation_list = self.state.attributes.get(water_heater.ATTR_OPERATION_LIST, [])
|
||||
|
||||
if state.value:
|
||||
mode = self._get_water_heater_operation(STATE_ON, operation_list)
|
||||
else:
|
||||
mode = self._get_water_heater_operation(STATE_OFF, operation_list)
|
||||
|
||||
if not mode:
|
||||
target_state_text = "on" if state.value else "off"
|
||||
raise APIError(
|
||||
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
f"Unable to determine operation mode for {target_state_text} state for {self}",
|
||||
)
|
||||
|
||||
await self._hass.services.async_call(
|
||||
water_heater.DOMAIN,
|
||||
water_heater.SERVICE_SET_OPERATION_MODE,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id, water_heater.ATTR_OPERATION_MODE: mode},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
def _get_water_heater_operation(self, required_mode: str, operations_list: list[str]) -> str | None:
|
||||
for operation in self._water_heater_operations[required_mode]:
|
||||
if operation in operations_list:
|
||||
return operation
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class OnOffCapabilityValve(OnOffCapability):
|
||||
"""Capability to open or close a valve."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return bool(self.state.domain == valve.DOMAIN)
|
||||
|
||||
def get_value(self) -> bool | None:
|
||||
"""Return the current capability value."""
|
||||
return self.state.state == STATE_OPEN
|
||||
|
||||
async def _set_instance_state(self, context: Context, state: OnOffCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
if state.value:
|
||||
service = SERVICE_OPEN_VALVE
|
||||
else:
|
||||
service = SERVICE_CLOSE_VALVE
|
||||
|
||||
await self._hass.services.async_call(
|
||||
valve.DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityBasic)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityAutomation)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityGroup)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityScript)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityButton)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityInputButton)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityLock)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityCover)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityRemote)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityMediaPlayer)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityVacuum)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityClimate)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityWaterHeater)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OnOffCapabilityValve)
|
||||
@@ -0,0 +1,742 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Implement the Yandex Smart Home toggle capabilities."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from homeassistant.components import cover, fan, light, media_player, vacuum
|
||||
from homeassistant.components.cover import CoverEntityFeature
|
||||
from homeassistant.components.fan import FanEntityFeature
|
||||
from homeassistant.components.media_player.const import MediaPlayerEntityFeature
|
||||
from homeassistant.components.vacuum import VacuumEntityFeature
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_PLAY,
|
||||
SERVICE_STOP_COVER,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
STATE_ON,
|
||||
STATE_PLAYING,
|
||||
)
|
||||
from homeassistant.core import Context
|
||||
|
||||
from .backports import VacuumActivity
|
||||
from .capability import STATE_CAPABILITIES_REGISTRY, ActionOnlyCapabilityMixin, Capability, StateCapability
|
||||
from .color import SOLID_LIGHT_EFFECT, LightState
|
||||
from .const import CONF_FEATURES, MediaPlayerFeature
|
||||
from .schema import (
|
||||
CapabilityType,
|
||||
ToggleCapabilityInstance,
|
||||
ToggleCapabilityInstanceActionState,
|
||||
ToggleCapabilityParameters,
|
||||
)
|
||||
|
||||
|
||||
class ToggleCapability(Capability[ToggleCapabilityInstanceActionState], Protocol):
|
||||
"""Base class for capabilities with toggle functions like mute or pause.
|
||||
|
||||
https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-docpage/
|
||||
"""
|
||||
|
||||
type: CapabilityType = CapabilityType.TOGGLE
|
||||
instance: ToggleCapabilityInstance
|
||||
|
||||
@property
|
||||
def parameters(self) -> ToggleCapabilityParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return ToggleCapabilityParameters(instance=self.instance)
|
||||
|
||||
|
||||
class StateToggleCapability(ToggleCapability, StateCapability[ToggleCapabilityInstanceActionState], Protocol):
|
||||
"""Base class for a toggle capability based on the state."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BacklightCapability(StateToggleCapability):
|
||||
"""Capability to represent state as backlight toggle."""
|
||||
|
||||
instance = ToggleCapabilityInstance.BACKLIGHT
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return True
|
||||
|
||||
def get_value(self) -> bool:
|
||||
"""Return the current capability value."""
|
||||
return self.state.state == STATE_ON
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
if state.value:
|
||||
service = SERVICE_TURN_ON
|
||||
else:
|
||||
service = SERVICE_TURN_OFF
|
||||
|
||||
await self._hass.services.async_call(
|
||||
self.state.domain,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class MuteCapability(StateToggleCapability):
|
||||
"""Capability to mute and unmute device."""
|
||||
|
||||
instance = ToggleCapabilityInstance.MUTE
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
if self.state.domain == media_player.DOMAIN:
|
||||
if self._state_features & MediaPlayerEntityFeature.VOLUME_MUTE:
|
||||
return True
|
||||
|
||||
if MediaPlayerFeature.VOLUME_MUTE in self._entity_config.get(CONF_FEATURES, []):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def retrievable(self) -> bool:
|
||||
"""Test if the capability can return the current value."""
|
||||
return media_player.ATTR_MEDIA_VOLUME_MUTED in self.state.attributes
|
||||
|
||||
def get_value(self) -> bool:
|
||||
"""Return the current capability value."""
|
||||
return bool(self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED))
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
await self._hass.services.async_call(
|
||||
media_player.DOMAIN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_VOLUME_MUTED: state.value},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class PauseCapabilityMediaPlayer(StateToggleCapability):
|
||||
"""Capability to pause and resume media player playback."""
|
||||
|
||||
instance = ToggleCapabilityInstance.PAUSE
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
if self.state.domain == media_player.DOMAIN:
|
||||
if MediaPlayerFeature.PLAY_PAUSE in self._entity_config.get(CONF_FEATURES, []):
|
||||
return True
|
||||
|
||||
if (
|
||||
self._state_features & MediaPlayerEntityFeature.PAUSE
|
||||
and self._state_features & MediaPlayerEntityFeature.PLAY
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_value(self) -> bool:
|
||||
"""Return the current capability value."""
|
||||
return bool(self.state.state != STATE_PLAYING)
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
if state.value:
|
||||
service = SERVICE_MEDIA_PAUSE
|
||||
else:
|
||||
service = SERVICE_MEDIA_PLAY
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class PauseCapabilityCover(ActionOnlyCapabilityMixin, StateToggleCapability):
|
||||
"""Capability to stop a cover."""
|
||||
|
||||
instance = ToggleCapabilityInstance.PAUSE
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == cover.DOMAIN and bool(self._state_features & CoverEntityFeature.STOP)
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
await self._hass.services.async_call(
|
||||
cover.DOMAIN,
|
||||
SERVICE_STOP_COVER,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class PauseCapabilityLight(ActionOnlyCapabilityMixin, StateToggleCapability, LightState):
|
||||
"""Capability to turn on solid light effect for a light device."""
|
||||
|
||||
instance = ToggleCapabilityInstance.PAUSE
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == light.DOMAIN and self._solid_effect_supported
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
await self._hass.services.async_call(
|
||||
light.DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_EFFECT: SOLID_LIGHT_EFFECT},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class PauseCapabilityVacuum(StateToggleCapability):
|
||||
"""Capability to stop a vacuum."""
|
||||
|
||||
instance = ToggleCapabilityInstance.PAUSE
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == vacuum.DOMAIN and bool(self._state_features & VacuumEntityFeature.PAUSE)
|
||||
|
||||
def get_value(self) -> bool:
|
||||
"""Return the current capability value."""
|
||||
return self.state.state == VacuumActivity.PAUSED
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
if state.value:
|
||||
service = vacuum.SERVICE_PAUSE
|
||||
else:
|
||||
service = vacuum.SERVICE_START
|
||||
|
||||
await self._hass.services.async_call(
|
||||
vacuum.DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
class OscillationCapability(StateToggleCapability):
|
||||
"""Capability to control fan oscillation."""
|
||||
|
||||
instance = ToggleCapabilityInstance.OSCILLATION
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == fan.DOMAIN and bool(self._state_features & FanEntityFeature.OSCILLATE)
|
||||
|
||||
def get_value(self) -> bool:
|
||||
"""Return the current capability value."""
|
||||
return bool(self.state.attributes.get(fan.ATTR_OSCILLATING))
|
||||
|
||||
async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None:
|
||||
"""Change the capability state."""
|
||||
await self._hass.services.async_call(
|
||||
fan.DOMAIN,
|
||||
fan.SERVICE_OSCILLATE,
|
||||
{ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_OSCILLATING: state.value},
|
||||
blocking=self._wait_for_service_call,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
STATE_CAPABILITIES_REGISTRY.register(MuteCapability)
|
||||
STATE_CAPABILITIES_REGISTRY.register(PauseCapabilityMediaPlayer)
|
||||
STATE_CAPABILITIES_REGISTRY.register(PauseCapabilityCover)
|
||||
STATE_CAPABILITIES_REGISTRY.register(PauseCapabilityLight)
|
||||
STATE_CAPABILITIES_REGISTRY.register(PauseCapabilityVacuum)
|
||||
STATE_CAPABILITIES_REGISTRY.register(OscillationCapability)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Implement the Yandex Smart Home video_stream capabilities."""
|
||||
|
||||
# pyright: reportAttributeAccessIssue=information
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import camera
|
||||
from homeassistant.components.camera import CameraEntityFeature, StreamType
|
||||
from homeassistant.components.stream import Stream
|
||||
from homeassistant.core import Context
|
||||
from homeassistant.helpers import network
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from . import DOMAIN
|
||||
from .capability import STATE_CAPABILITIES_REGISTRY, ActionOnlyCapabilityMixin, StateCapability
|
||||
from .cloud_stream import CloudStreamManager
|
||||
from .helpers import APIError
|
||||
from .schema import (
|
||||
CapabilityType,
|
||||
GetStreamInstanceActionResultValue,
|
||||
GetStreamInstanceActionState,
|
||||
ResponseCode,
|
||||
VideoStreamCapabilityInstance,
|
||||
VideoStreamCapabilityParameters,
|
||||
)
|
||||
|
||||
try:
|
||||
from homeassistant.components.camera import get_camera_from_entity_id
|
||||
except ImportError: # pragma: no cover
|
||||
from homeassistant.components.camera import ( # type: ignore[no-redef]
|
||||
_get_camera_from_entity_id as get_camera_from_entity_id,
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import YandexSmartHome
|
||||
|
||||
|
||||
class VideoStreamCapability(ActionOnlyCapabilityMixin, StateCapability[GetStreamInstanceActionState]):
|
||||
"""Capability to stream from cameras."""
|
||||
|
||||
type = CapabilityType.VIDEO_STREAM
|
||||
instance = VideoStreamCapabilityInstance.GET_STREAM
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the capability is supported."""
|
||||
return self.state.domain == camera.DOMAIN and bool(self._state_features & CameraEntityFeature.STREAM)
|
||||
|
||||
@property
|
||||
def parameters(self) -> VideoStreamCapabilityParameters:
|
||||
"""Return parameters for a devices request."""
|
||||
return VideoStreamCapabilityParameters(protocols=["hls"])
|
||||
|
||||
async def set_instance_state(
|
||||
self, context: Context, state: GetStreamInstanceActionState
|
||||
) -> GetStreamInstanceActionResultValue:
|
||||
"""Change capability instance state."""
|
||||
component: YandexSmartHome = self._hass.data[DOMAIN]
|
||||
entity_id = self.state.entity_id
|
||||
stream = await self._async_request_stream(entity_id)
|
||||
|
||||
if self._entry_data.use_cloud_stream:
|
||||
cloud_stream = component.cloud_streams.get(entity_id)
|
||||
if not cloud_stream:
|
||||
cloud_stream = CloudStreamManager(self._hass, stream, async_get_clientsession(self._hass))
|
||||
component.cloud_streams[entity_id] = cloud_stream
|
||||
|
||||
await cloud_stream.async_start()
|
||||
stream_url = cloud_stream.stream_url
|
||||
if not stream_url:
|
||||
raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, "Failed to start stream")
|
||||
else:
|
||||
try:
|
||||
external_url = network.get_url(self._hass, allow_internal=False)
|
||||
except network.NoURLAvailableError:
|
||||
raise APIError(
|
||||
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
"Missing Home Assistant external URL. Have you set external URLs in Configuration -> General?",
|
||||
)
|
||||
|
||||
endpoint_url = stream.endpoint_url(StreamType.HLS)
|
||||
stream_url = f"{external_url}{endpoint_url}"
|
||||
|
||||
return GetStreamInstanceActionResultValue(stream_url=stream_url, protocol="hls")
|
||||
|
||||
async def _async_request_stream(self, entity_id: str) -> Stream:
|
||||
camera_entity = get_camera_from_entity_id(self._hass, self.state.entity_id)
|
||||
stream = await camera_entity.async_create_stream()
|
||||
|
||||
if not stream:
|
||||
raise APIError(
|
||||
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"{entity_id} does not support play stream service"
|
||||
)
|
||||
|
||||
stream.add_provider(StreamType.HLS)
|
||||
|
||||
await stream.start()
|
||||
|
||||
stream.endpoint_url(StreamType.HLS)
|
||||
|
||||
return stream
|
||||
|
||||
|
||||
STATE_CAPABILITIES_REGISTRY.register(VideoStreamCapability)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Implement the Yandex Smart Home cloud connection manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import TimeoutError
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, AsyncIterable, cast
|
||||
|
||||
from aiohttp import ClientConnectorError, ClientResponseError, ClientWebSocketResponse, WSMessage, WSMsgType, hdrs
|
||||
from homeassistant.core import CALLBACK_TYPE, Context, HassJob, HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE, async_create_clientsession, async_get_clientsession
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.util import dt
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from . import handlers
|
||||
from .const import CLOUD_BASE_URL, DOMAIN, ISSUE_ID_RECONNECTING_TOO_FAST
|
||||
from .helpers import RequestData, SmartHomePlatform
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entry_data import ConfigEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_RECONNECTION_DELAY = 2
|
||||
MAX_RECONNECTION_DELAY = 180
|
||||
FAST_RECONNECTION_TIME = timedelta(seconds=6)
|
||||
FAST_RECONNECTION_THRESHOLD = 5
|
||||
BASE_API_URL = f"{CLOUD_BASE_URL}/api/home_assistant/v1"
|
||||
|
||||
|
||||
class CloudInstanceData(BaseModel):
|
||||
"""Hold settings for the cloud connection."""
|
||||
|
||||
id: str
|
||||
password: str
|
||||
connection_token: str
|
||||
|
||||
|
||||
class CloudInstanceOTP(BaseModel):
|
||||
"""Hold response for one time password request."""
|
||||
|
||||
code: str
|
||||
|
||||
|
||||
class CloudRequest(BaseModel):
|
||||
"""Request from the cloud."""
|
||||
|
||||
request_id: str
|
||||
platform: SmartHomePlatform
|
||||
action: str
|
||||
message: str = ""
|
||||
|
||||
|
||||
class CloudManager:
|
||||
"""Class to manage cloud connection."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData):
|
||||
"""Initialize a cloud manager with entry data and client session."""
|
||||
self._hass = hass
|
||||
self._entry_data = entry_data
|
||||
self._session = async_get_clientsession(hass)
|
||||
self._last_connection_at: datetime | None = None
|
||||
self._fast_reconnection_count = 0
|
||||
self._ws: ClientWebSocketResponse | None = None
|
||||
self._ws_reconnect_delay = DEFAULT_RECONNECTION_DELAY
|
||||
self._ws_active = True
|
||||
self._unsub_connect: CALLBACK_TYPE | None = None
|
||||
|
||||
self._url = f"{BASE_API_URL}/connect"
|
||||
|
||||
async def async_connect(self, *_: Any) -> None:
|
||||
"""Connect to the cloud."""
|
||||
try:
|
||||
_LOGGER.debug(f"Connecting to {self._url}")
|
||||
self._ws = await self._session.ws_connect(
|
||||
self._url,
|
||||
heartbeat=45,
|
||||
compress=15,
|
||||
headers={
|
||||
hdrs.AUTHORIZATION: f"Bearer {self._entry_data.cloud_connection_token}",
|
||||
hdrs.USER_AGENT: f"{SERVER_SOFTWARE} {DOMAIN}/{self._entry_data.component_version}",
|
||||
},
|
||||
)
|
||||
|
||||
_LOGGER.debug("Connection to Yandex Smart Home cloud established")
|
||||
self._ws_reconnect_delay = DEFAULT_RECONNECTION_DELAY
|
||||
self._last_connection_at = dt.utcnow()
|
||||
ir.async_delete_issue(self._hass, DOMAIN, ISSUE_ID_RECONNECTING_TOO_FAST)
|
||||
|
||||
async for msg in cast(AsyncIterable[WSMessage], self._ws):
|
||||
if msg.type == WSMsgType.TEXT:
|
||||
await self._on_message(msg)
|
||||
|
||||
_LOGGER.debug(f"Disconnected: {self._ws.close_code}")
|
||||
if self._ws.close_code is not None:
|
||||
self._try_reconnect()
|
||||
except (ClientConnectorError, ClientResponseError, TimeoutError):
|
||||
_LOGGER.exception("Failed to connect to Yandex Smart Home cloud")
|
||||
self._try_reconnect()
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
self._try_reconnect()
|
||||
|
||||
return None
|
||||
|
||||
async def async_disconnect(self, *_: Any) -> None:
|
||||
"""Disconnect from the cloud."""
|
||||
self._ws_active = False
|
||||
if self._ws:
|
||||
await self._ws.close()
|
||||
|
||||
if self._unsub_connect:
|
||||
self._unsub_connect()
|
||||
self._unsub_connect = None
|
||||
|
||||
return None
|
||||
|
||||
async def _on_message(self, message: WSMessage) -> None:
|
||||
"""Handle incoming request from the cloud."""
|
||||
request = CloudRequest.parse_raw(message.data)
|
||||
_LOGGER.debug("Request: %s (message: %s)" % (request.action, request.message))
|
||||
|
||||
data = RequestData(
|
||||
entry_data=self._entry_data,
|
||||
context=Context(user_id=await self._entry_data.async_get_context_user_id()),
|
||||
platform=request.platform,
|
||||
request_user_id=self._entry_data.cloud_instance_id,
|
||||
request_id=request.request_id,
|
||||
)
|
||||
|
||||
result = await handlers.async_handle_request(self._hass, data, request.action, request.message)
|
||||
response = result.as_json()
|
||||
_LOGGER.debug(f"Response: {response}")
|
||||
|
||||
assert self._ws is not None
|
||||
await self._ws.send_str(response)
|
||||
return None
|
||||
|
||||
def _try_reconnect(self) -> None:
|
||||
"""Schedule reconnection to the cloud."""
|
||||
if not self._ws_active:
|
||||
return None
|
||||
|
||||
self._ws_reconnect_delay = min(2 * self._ws_reconnect_delay, MAX_RECONNECTION_DELAY)
|
||||
|
||||
if self._last_connection_at and self._last_connection_at + FAST_RECONNECTION_TIME > dt.utcnow():
|
||||
self._fast_reconnection_count += 1
|
||||
else:
|
||||
self._fast_reconnection_count = 0
|
||||
|
||||
if self._fast_reconnection_count >= FAST_RECONNECTION_THRESHOLD:
|
||||
self._ws_reconnect_delay = MAX_RECONNECTION_DELAY
|
||||
ir.async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
ISSUE_ID_RECONNECTING_TOO_FAST,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.CRITICAL,
|
||||
translation_key=ISSUE_ID_RECONNECTING_TOO_FAST,
|
||||
translation_placeholders={"entry_title": self._entry_data.entry.title},
|
||||
)
|
||||
_LOGGER.warning(f"Reconnecting too fast, next reconnection in {self._ws_reconnect_delay} seconds")
|
||||
|
||||
_LOGGER.debug(f"Trying to reconnect in {self._ws_reconnect_delay} seconds")
|
||||
self._unsub_connect = async_call_later(self._hass, self._ws_reconnect_delay, HassJob(self.async_connect))
|
||||
return None
|
||||
|
||||
|
||||
async def register_instance(hass: HomeAssistant, platform: SmartHomePlatform | None = None) -> CloudInstanceData:
|
||||
"""Register a new cloud instance."""
|
||||
session = async_create_clientsession(hass)
|
||||
|
||||
if platform:
|
||||
response = await session.post(f"{BASE_API_URL}/instance/register", json={"platform": platform.value})
|
||||
else:
|
||||
response = await session.post(f"{BASE_API_URL}/instance/register")
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return CloudInstanceData.parse_raw(await response.text())
|
||||
|
||||
|
||||
async def get_instance_otp(hass: HomeAssistant, instance_id: str, token: str) -> str:
|
||||
"""Return one time password for a cloud instance linking."""
|
||||
session = async_create_clientsession(hass)
|
||||
|
||||
response = await session.post(
|
||||
f"{BASE_API_URL}/instance/{instance_id}/otp",
|
||||
headers={hdrs.AUTHORIZATION: f"Bearer {token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return CloudInstanceOTP.parse_raw(await response.text()).code
|
||||
|
||||
|
||||
async def reset_connection_token(hass: HomeAssistant, instance_id: str, token: str) -> CloudInstanceData:
|
||||
"""Reset a cloud instance connection token."""
|
||||
session = async_create_clientsession(hass)
|
||||
|
||||
response = await session.post(
|
||||
f"{BASE_API_URL}/instance/{instance_id}/reset-connection-token",
|
||||
headers={hdrs.AUTHORIZATION: f"Bearer {token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return CloudInstanceData.parse_raw(await response.text())
|
||||
|
||||
|
||||
async def revoke_oauth_tokens(hass: HomeAssistant, instance_id: str, token: str) -> None:
|
||||
"""Revoke all access and refresh tokens for a cloud instance."""
|
||||
session = async_create_clientsession(hass)
|
||||
|
||||
response = await session.post(
|
||||
f"{BASE_API_URL}/instance/{instance_id}/oauth/revoke-all",
|
||||
headers={hdrs.AUTHORIZATION: f"Bearer {token}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Implement the Yandex Smart Home cloud connection manager for video streaming."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any, AsyncIterable, cast
|
||||
|
||||
from aiohttp import (
|
||||
ClientConnectionError,
|
||||
ClientResponseError,
|
||||
ClientSession,
|
||||
ClientWebSocketResponse,
|
||||
WSMessage,
|
||||
WSMsgType,
|
||||
web,
|
||||
)
|
||||
from aiohttp.web_request import Request as AIOWebRequest
|
||||
from homeassistant.components.stream import Stream
|
||||
from homeassistant.components.stream.core import StreamView
|
||||
from homeassistant.components.stream.hls import (
|
||||
HlsInitView,
|
||||
HlsMasterPlaylistView,
|
||||
HlsPartView,
|
||||
HlsPlaylistView,
|
||||
HlsSegmentView,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.http import KEY_HASS
|
||||
from multidict import MultiDictProxy
|
||||
from pydantic.v1 import BaseModel
|
||||
import yarl
|
||||
|
||||
from .const import CLOUD_STREAM_BASE_URL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
RECONNECTION_DELAY = 2
|
||||
WAIT_FOR_CONNECTION_TIMEOUT = 10
|
||||
|
||||
|
||||
class Request(BaseModel):
|
||||
"""Request from the cloud."""
|
||||
|
||||
view: str
|
||||
sequence: str = ""
|
||||
part_num: str = ""
|
||||
url_query: str | None
|
||||
|
||||
|
||||
class ResponseMeta(BaseModel):
|
||||
"""Response metadata."""
|
||||
|
||||
status_code: int
|
||||
headers: dict[str, str]
|
||||
|
||||
|
||||
class WebRequest:
|
||||
"""Represent minimal HTTP request to use in HomeAssistantView"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, url: yarl.URL):
|
||||
"""Initialize web request from url."""
|
||||
self.app = {KEY_HASS: hass}
|
||||
self._url = url
|
||||
|
||||
@property
|
||||
def query(self) -> MultiDictProxy[str]:
|
||||
"""Return parsed query parameters in decoded representation."""
|
||||
return MultiDictProxy(self._url.query)
|
||||
|
||||
|
||||
class CloudStreamManager:
|
||||
"""Class to manage cloud connection for streaming."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, stream: Stream, session: ClientSession):
|
||||
"""Initialize a cloud manager with stream and client session."""
|
||||
|
||||
self._hass = hass
|
||||
self._stream = stream
|
||||
self._running_stream_id: str | None = None
|
||||
self._session = session
|
||||
self._connected = asyncio.Event()
|
||||
self._ws: ClientWebSocketResponse | None = None
|
||||
self._unsub_connect: CALLBACK_TYPE | None = None
|
||||
self._unsub_keepalive: CALLBACK_TYPE | None = None
|
||||
|
||||
@property
|
||||
def stream_url(self) -> str | None:
|
||||
"""Return URL to stream."""
|
||||
if not self._running_stream_id:
|
||||
return None
|
||||
|
||||
return f"{CLOUD_STREAM_BASE_URL}/{self._running_stream_id}/master_playlist.m3u8"
|
||||
|
||||
async def async_start(self) -> None:
|
||||
"""Start connection."""
|
||||
if self._ws or not self._stream.access_token:
|
||||
return
|
||||
|
||||
self._running_stream_id = self._stream.access_token
|
||||
self._hass.loop.create_task(self._async_connect())
|
||||
|
||||
await asyncio.wait_for(self._connected.wait(), timeout=WAIT_FOR_CONNECTION_TIMEOUT)
|
||||
return await self._async_keepalive()
|
||||
|
||||
async def _async_keepalive(self, *_: Any) -> None:
|
||||
"""Disconnect if stream is not active anymore."""
|
||||
if self._stream.access_token != self._running_stream_id:
|
||||
return await self._async_disconnect()
|
||||
|
||||
self._unsub_keepalive = async_call_later(self._hass, timedelta(seconds=1), HassJob(self._async_keepalive))
|
||||
return None
|
||||
|
||||
async def _async_connect(self, *_: Any) -> None:
|
||||
"""Connect to the cloud."""
|
||||
if not self._running_stream_id:
|
||||
return
|
||||
|
||||
ws_url = f"{CLOUD_STREAM_BASE_URL}/{self._running_stream_id}/connect"
|
||||
|
||||
try:
|
||||
_LOGGER.debug(f"Connecting to {ws_url}")
|
||||
self._ws = await self._session.ws_connect(ws_url, heartbeat=30)
|
||||
|
||||
_LOGGER.debug("Connection to Yandex Smart Home cloud established")
|
||||
self._connected.set()
|
||||
|
||||
async for msg in cast(AsyncIterable[WSMessage], self._ws):
|
||||
if msg.type == WSMsgType.TEXT:
|
||||
await self._on_message(msg)
|
||||
|
||||
_LOGGER.debug(f"Disconnected: {self._ws.close_code}")
|
||||
if self._ws.close_code is not None:
|
||||
self._try_reconnect()
|
||||
except (ClientConnectionError, ClientResponseError, asyncio.TimeoutError):
|
||||
_LOGGER.exception("Failed to connect to Yandex Smart Home cloud")
|
||||
self._try_reconnect()
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
self._try_reconnect()
|
||||
|
||||
return None
|
||||
|
||||
async def _async_disconnect(self, *_: Any) -> None:
|
||||
"""Disconnect from the cloud."""
|
||||
self._running_stream_id = None
|
||||
self._connected.clear()
|
||||
|
||||
if self._ws:
|
||||
await self._ws.close()
|
||||
self._ws = None
|
||||
|
||||
for unsub in [self._unsub_connect, self._unsub_keepalive]:
|
||||
if unsub:
|
||||
unsub()
|
||||
|
||||
self._unsub_connect = None
|
||||
self._unsub_keepalive = None
|
||||
|
||||
return None
|
||||
|
||||
async def _on_message(self, message: WSMessage) -> None:
|
||||
"""Handle incoming request from the cloud."""
|
||||
_LOGGER.debug(f"Request: {message.data}")
|
||||
|
||||
request = Request.parse_raw(message.data)
|
||||
request_url = yarl.URL.build(path=f"{request.view}", query=request.url_query)
|
||||
web_request = cast(AIOWebRequest, WebRequest(self._hass, request_url))
|
||||
|
||||
views: dict[str, type[StreamView]] = {
|
||||
"master_playlist": HlsMasterPlaylistView,
|
||||
"playlist": HlsPlaylistView,
|
||||
"init": HlsInitView,
|
||||
"part": HlsPartView,
|
||||
"segment": HlsSegmentView,
|
||||
}
|
||||
|
||||
view = views[request.view]()
|
||||
|
||||
r = cast(
|
||||
web.Response,
|
||||
await view.get(web_request, self._stream.access_token or "", request.sequence, request.part_num),
|
||||
)
|
||||
assert self._ws is not None
|
||||
body = r.body if r.body is not None else b""
|
||||
assert isinstance(body, bytes)
|
||||
meta = ResponseMeta(status_code=r.status, headers=dict(r.headers))
|
||||
response = bytes(meta.json(), "utf-8") + b"\r\n" + body
|
||||
return await self._ws.send_bytes(response, compress=False)
|
||||
|
||||
def _try_reconnect(self) -> None:
|
||||
"""Schedule reconnection to the cloud."""
|
||||
|
||||
_LOGGER.debug(f"Trying to reconnect in {RECONNECTION_DELAY} seconds")
|
||||
self._unsub_reconnect = async_call_later(self._hass, RECONNECTION_DELAY, HassJob(self._async_connect))
|
||||
return None
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Color manipulation helpers."""
|
||||
|
||||
from enum import StrEnum
|
||||
from functools import cached_property
|
||||
from math import sqrt
|
||||
from typing import Final, Protocol, Self
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_EFFECT_LIST,
|
||||
ATTR_HS_COLOR,
|
||||
ATTR_MAX_COLOR_TEMP_KELVIN,
|
||||
ATTR_MIN_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_RGBW_COLOR,
|
||||
ATTR_RGBWW_COLOR,
|
||||
ATTR_SUPPORTED_COLOR_MODES,
|
||||
ATTR_XY_COLOR,
|
||||
ColorMode,
|
||||
)
|
||||
from homeassistant.core import State
|
||||
from homeassistant.util.color import RGBColor, color_hs_to_RGB, color_xy_to_RGB
|
||||
|
||||
SOLID_LIGHT_EFFECT: Final = "Solid"
|
||||
|
||||
|
||||
class ColorName(StrEnum):
|
||||
RED = "red"
|
||||
CORAL = "coral"
|
||||
ORANGE = "orange"
|
||||
YELLOW = "yellow"
|
||||
LIME = "lime"
|
||||
GREEN = "green"
|
||||
EMERALD = "emerald"
|
||||
TURQUOISE = "turquoise"
|
||||
CYAN = "cyan"
|
||||
BLUE = "blue"
|
||||
MOONLIGHT = "moonlight"
|
||||
LAVENDER = "lavender"
|
||||
VIOLET = "violet"
|
||||
PURPLE = "purple"
|
||||
ORCHID = "orchid"
|
||||
MAUVE = "mauve"
|
||||
RASPBERRY = "raspberry"
|
||||
|
||||
FIERY_WHITE = "fiery_white"
|
||||
SOFT_WHITE = "soft_white"
|
||||
WARM_WHITE = "warm_white"
|
||||
WHITE = "white"
|
||||
DAYLIGHT = "daylight"
|
||||
COLD_WHITE = "cold_white"
|
||||
MISTY_WHITE = "misty_white"
|
||||
HEAVENLY_WHITE = "heavenly_white"
|
||||
|
||||
|
||||
def rgb_to_int(color: RGBColor) -> int:
|
||||
"""Convert a rgb color to int value."""
|
||||
return (color.r << 16) + (color.g << 8) + color.b
|
||||
|
||||
|
||||
def int_to_rgb(i: int) -> RGBColor:
|
||||
"""Convert int value to a rgb color."""
|
||||
return RGBColor(r=(i >> 16) & 0xFF, g=(i >> 8) & 0xFF, b=i & 0xFF)
|
||||
|
||||
|
||||
ColorProfile = dict[ColorName, int]
|
||||
"""Hold int value for color/temperature name."""
|
||||
|
||||
|
||||
class ColorProfiles(dict[str, ColorProfile]):
|
||||
"""Represent color profiles."""
|
||||
|
||||
_default_profiles = {
|
||||
"natural": {
|
||||
ColorName.RED: 16711680,
|
||||
ColorName.YELLOW: 16760576,
|
||||
ColorName.GREEN: 65280,
|
||||
ColorName.EMERALD: 2424612,
|
||||
ColorName.TURQUOISE: 65471,
|
||||
ColorName.CYAN: 65535,
|
||||
ColorName.BLUE: 255,
|
||||
ColorName.MOONLIGHT: 16763025,
|
||||
ColorName.LAVENDER: 4129023,
|
||||
ColorName.VIOLET: 8323327,
|
||||
ColorName.PURPLE: 12517631,
|
||||
ColorName.ORCHID: 16711765,
|
||||
ColorName.RASPBERRY: 16713260,
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, dict[str, int]]) -> Self:
|
||||
"""Intialize the color profiles from a dict."""
|
||||
profiles = cls._default_profiles.copy()
|
||||
for profile_name, mapping in data.items():
|
||||
profiles.setdefault(profile_name, {})
|
||||
profiles[profile_name].update({ColorName(name): v for name, v in mapping.items()})
|
||||
|
||||
return cls(profiles)
|
||||
|
||||
|
||||
class ColorConverter:
|
||||
"""Utility to convert Yandex color to HA and vise-versa."""
|
||||
|
||||
_palette = {
|
||||
ColorName.RED: 16714250,
|
||||
ColorName.CORAL: 16729907,
|
||||
ColorName.ORANGE: 16727040,
|
||||
ColorName.YELLOW: 16740362,
|
||||
ColorName.LIME: 13303562,
|
||||
ColorName.GREEN: 720711,
|
||||
ColorName.EMERALD: 720813,
|
||||
ColorName.TURQUOISE: 720883,
|
||||
ColorName.CYAN: 710399,
|
||||
ColorName.BLUE: 673791,
|
||||
ColorName.MOONLIGHT: 15067647,
|
||||
ColorName.LAVENDER: 8719103,
|
||||
ColorName.VIOLET: 11340543,
|
||||
ColorName.PURPLE: 16714471,
|
||||
ColorName.ORCHID: 16714393,
|
||||
ColorName.MAUVE: 16722742,
|
||||
ColorName.RASPBERRY: 16711765,
|
||||
}
|
||||
|
||||
def __init__(self, profile: ColorProfile | None = None):
|
||||
"""Initialize the color converter from color profile."""
|
||||
profile = profile or {}
|
||||
|
||||
self._yandex_mapping: dict[int, int] = {}
|
||||
self._ha_mapping: dict[int, int] = {}
|
||||
|
||||
for color_name, yandex_value in self._palette.items():
|
||||
ha_value = profile.get(color_name, yandex_value)
|
||||
|
||||
self._yandex_mapping[yandex_value] = ha_value
|
||||
self._ha_mapping[ha_value] = yandex_value
|
||||
|
||||
def get_ha_color(self, yandex_color: int) -> RGBColor:
|
||||
"""Return HA color for Yandex color."""
|
||||
return int_to_rgb(self._yandex_mapping.get(yandex_color, yandex_color))
|
||||
|
||||
def get_yandex_color(self, ha_color: RGBColor) -> int:
|
||||
"""Return Yandex color for HA color."""
|
||||
for from_ha_value, to_yandex_value in self._ha_mapping.items():
|
||||
if self._distance(ha_color, int_to_rgb(from_ha_value)) <= 2:
|
||||
return to_yandex_value
|
||||
|
||||
return rgb_to_int(ha_color)
|
||||
|
||||
@staticmethod
|
||||
def _distance(a: RGBColor, b: RGBColor) -> float:
|
||||
"""Return a distance between two colors."""
|
||||
return abs(sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2))
|
||||
|
||||
|
||||
class ColorTemperatureConverter:
|
||||
"""Utility to convert Yandex color temperature to HA and vise-versa."""
|
||||
|
||||
default_white_temperature = 4500
|
||||
|
||||
_palette = {
|
||||
ColorName.FIERY_WHITE: 1500,
|
||||
ColorName.SOFT_WHITE: 2700,
|
||||
ColorName.WARM_WHITE: 3400,
|
||||
ColorName.WHITE: 4500,
|
||||
ColorName.DAYLIGHT: 5600,
|
||||
ColorName.COLD_WHITE: 6500,
|
||||
ColorName.MISTY_WHITE: 7500,
|
||||
ColorName.HEAVENLY_WHITE: 9000,
|
||||
}
|
||||
_temperature_steps = sorted(_palette.values())
|
||||
|
||||
def __init__(self, profile: ColorProfile | None, state: State):
|
||||
"""Initialize the color temperature converter from color profile."""
|
||||
|
||||
self._yandex_mapping: dict[int, int] = {}
|
||||
self._ha_mapping: dict[int, int] = {}
|
||||
|
||||
profile = profile or {}
|
||||
range_extend_threshold = 200
|
||||
min_color_temp = self._round_color_temperature(int(state.attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, 2000)))
|
||||
max_color_temp = self._round_color_temperature(int(state.attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, 6500)))
|
||||
|
||||
for color_name, yandex_value in self._palette.items():
|
||||
ha_value = self._round_color_temperature(profile.get(color_name, yandex_value))
|
||||
if ha_value < min_color_temp or ha_value > max_color_temp:
|
||||
continue
|
||||
|
||||
self._map_values(yandex_value, ha_value)
|
||||
|
||||
if self._ha_mapping and self._yandex_mapping:
|
||||
if min_color_temp + range_extend_threshold < min(self._ha_mapping):
|
||||
if yandex_color_temp := self._first_available_temperature_step:
|
||||
self._map_values(yandex_color_temp, min_color_temp)
|
||||
|
||||
if max_color_temp - range_extend_threshold > max(self._ha_mapping):
|
||||
if yandex_color_temp := self._last_available_temperature_step:
|
||||
self._map_values(yandex_color_temp, max_color_temp)
|
||||
else:
|
||||
self._ha_mapping[min_color_temp] = self.default_white_temperature
|
||||
self._yandex_mapping[self.default_white_temperature] = min_color_temp
|
||||
|
||||
def get_ha_color_temperature(self, yandex_color_temperature: int) -> int:
|
||||
"""Return HA color temperature for Yandex color temperature."""
|
||||
return self._yandex_mapping.get(yandex_color_temperature, yandex_color_temperature)
|
||||
|
||||
def get_yandex_color_temperature(self, ha_color_temperature: int) -> int:
|
||||
"""Return Yandex color temperature for HA color temperature."""
|
||||
color_temperature = self._round_color_temperature(ha_color_temperature)
|
||||
return self._ha_mapping.get(color_temperature, color_temperature)
|
||||
|
||||
@property
|
||||
def supported_range(self) -> tuple[int, int]:
|
||||
"""Return temperature range supported for the state."""
|
||||
return min(self._yandex_mapping), max(self._yandex_mapping)
|
||||
|
||||
@staticmethod
|
||||
def _round_color_temperature(color_temperature: int) -> int:
|
||||
"""Return kelvin temperature with decreased precision."""
|
||||
return round(color_temperature, -2)
|
||||
|
||||
@property
|
||||
def _first_available_temperature_step(self) -> int | None:
|
||||
"""Return additional minimal temperature that outside mapped temperature range."""
|
||||
min_color_temp = min(self._yandex_mapping)
|
||||
idx = self._temperature_steps.index(min_color_temp)
|
||||
if idx != 0:
|
||||
return self._temperature_steps[idx - 1]
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def _last_available_temperature_step(self) -> int | None:
|
||||
"""Return additional maximal temperature that outside mapped temperature range."""
|
||||
max_color_temp = max(self._yandex_mapping)
|
||||
idx = self._temperature_steps.index(max_color_temp)
|
||||
try:
|
||||
return self._temperature_steps[idx + 1]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _map_values(self, yandex_value: int, ha_value: int) -> None:
|
||||
"""Add mapping between yandex values and HA."""
|
||||
self._yandex_mapping[yandex_value] = ha_value
|
||||
self._ha_mapping[ha_value] = yandex_value
|
||||
return None
|
||||
|
||||
|
||||
class LightState(Protocol):
|
||||
"""Helper class for the state of a light device."""
|
||||
|
||||
state: State
|
||||
|
||||
@cached_property
|
||||
def _supported_color_modes(self) -> set[ColorMode]:
|
||||
"""Return a set of supported color modes."""
|
||||
return set(self.state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, []))
|
||||
|
||||
@cached_property
|
||||
def _rgb_color(self) -> RGBColor | None:
|
||||
"""Return current RGB color."""
|
||||
rgb_color: tuple[int, ...] | None = None
|
||||
|
||||
if ColorMode.RGBWW in self._supported_color_modes:
|
||||
rgb_color = self.state.attributes.get(ATTR_RGBWW_COLOR)
|
||||
elif ColorMode.RGBW in self._supported_color_modes:
|
||||
rgb_color = self.state.attributes.get(ATTR_RGBW_COLOR)
|
||||
else:
|
||||
rgb_color = self.state.attributes.get(ATTR_RGB_COLOR)
|
||||
|
||||
if rgb_color:
|
||||
return RGBColor(*rgb_color[:3])
|
||||
|
||||
if ColorMode.HS in self._supported_color_modes:
|
||||
hs_color: tuple[float, float] | None = self.state.attributes.get(ATTR_HS_COLOR)
|
||||
if hs_color:
|
||||
return RGBColor(*color_hs_to_RGB(*hs_color))
|
||||
|
||||
xy_color: tuple[float, float] | None = self.state.attributes.get(ATTR_XY_COLOR)
|
||||
if xy_color:
|
||||
return RGBColor(*color_xy_to_RGB(*xy_color, Gamut=None))
|
||||
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def _white_brightness(self) -> int | None:
|
||||
"""Return current white brightness or cold white brightness."""
|
||||
rgbw_color: tuple[int, ...] | None = None
|
||||
|
||||
if ColorMode.RGBWW in self._supported_color_modes:
|
||||
rgbw_color = self.state.attributes.get(ATTR_RGBWW_COLOR)
|
||||
elif ColorMode.RGBW in self._supported_color_modes:
|
||||
rgbw_color = self.state.attributes.get(ATTR_RGBW_COLOR)
|
||||
|
||||
if rgbw_color:
|
||||
return rgbw_color[3]
|
||||
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def _warm_white_brightness(self) -> int | None:
|
||||
"""Return current warm white brightness."""
|
||||
|
||||
if ColorMode.RGBWW in self._supported_color_modes:
|
||||
rgbww_color: tuple[int, int, int, int, int] | None = self.state.attributes.get(ATTR_RGBWW_COLOR)
|
||||
if rgbww_color:
|
||||
return rgbww_color[4]
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def _solid_effect_supported(self) -> bool:
|
||||
"""Check if the solid light effect is supported by the state."""
|
||||
return SOLID_LIGHT_EFFECT in (self.state.attributes.get(ATTR_EFFECT_LIST) or [])
|
||||
@@ -0,0 +1,761 @@
|
||||
"""Config flow for the Yandex Smart Home integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from aiohttp import ClientConnectorError, ClientResponseError
|
||||
from homeassistant.auth.const import GROUP_ID_READ_ONLY
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.const import CONF_ENTITIES, CONF_ID, CONF_NAME, CONF_PLATFORM, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow, FlowHandler
|
||||
from homeassistant.helpers import entity_registry as er, network, selector
|
||||
from homeassistant.helpers.entityfilter import CONF_INCLUDE_ENTITIES, FILTER_SCHEMA, EntityFilter
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
LabelSelector,
|
||||
LabelSelectorConfig,
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.setup import async_setup_component
|
||||
import voluptuous as vol
|
||||
|
||||
from . import DOMAIN, cloud
|
||||
from .const import (
|
||||
CLOUD_BASE_URL,
|
||||
CONF_CLOUD_INSTANCE,
|
||||
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN,
|
||||
CONF_CLOUD_INSTANCE_ID,
|
||||
CONF_CLOUD_INSTANCE_OTP,
|
||||
CONF_CLOUD_INSTANCE_PASSWORD,
|
||||
CONF_CONNECTION_TYPE,
|
||||
CONF_ENTRY_ALIASES,
|
||||
CONF_FILTER,
|
||||
CONF_FILTER_SOURCE,
|
||||
CONF_LABEL,
|
||||
CONF_LINKED_PLATFORMS,
|
||||
CONF_SKILL,
|
||||
CONF_USER_ID,
|
||||
ConnectionType,
|
||||
EntityFilterSource,
|
||||
)
|
||||
from .helpers import SmartHomePlatform
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigFlowContext # noqa: F401
|
||||
|
||||
from . import YandexSmartHome
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CONFIG_ENTRY_TITLE = "Yandex Smart Home"
|
||||
PRE_V1_DIRECT_CONFIG_ENTRY_TITLE = "YSH: Direct" # TODO: remove after v1.1 release
|
||||
USER_NONE = "none"
|
||||
|
||||
|
||||
class MaintenanceAction(StrEnum):
|
||||
REVOKE_OAUTH_TOKENS = "revoke_oauth_tokens"
|
||||
UNLINK_ALL_PLATFORMS = "unlink_all_platforms"
|
||||
RESET_CLOUD_INSTANCE_CONNECTION_TOKEN = "reset_cloud_instance_connection_token"
|
||||
TRANSFER_ENTITY_FILTER_FROM_YAML = "transfer_entity_filter_from_yaml"
|
||||
|
||||
|
||||
CONNECTION_TYPE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.LIST,
|
||||
translation_key=CONF_CONNECTION_TYPE,
|
||||
options=[
|
||||
SelectOptionDict(value=ConnectionType.CLOUD, label=ConnectionType.CLOUD),
|
||||
SelectOptionDict(value=ConnectionType.CLOUD_PLUS, label=ConnectionType.CLOUD_PLUS),
|
||||
SelectOptionDict(value=ConnectionType.DIRECT, label=ConnectionType.DIRECT),
|
||||
],
|
||||
),
|
||||
)
|
||||
PLATFORM_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.LIST,
|
||||
translation_key=CONF_PLATFORM,
|
||||
options=[
|
||||
SelectOptionDict(value=SmartHomePlatform.YANDEX, label=SmartHomePlatform.YANDEX),
|
||||
SelectOptionDict(value=SmartHomePlatform.VK, label=SmartHomePlatform.VK),
|
||||
],
|
||||
),
|
||||
)
|
||||
FILTER_SOURCE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.LIST,
|
||||
translation_key=CONF_FILTER_SOURCE,
|
||||
options=[
|
||||
SelectOptionDict(value=EntityFilterSource.CONFIG_ENTRY, label=EntityFilterSource.CONFIG_ENTRY),
|
||||
SelectOptionDict(
|
||||
value=EntityFilterSource.GET_FROM_CONFIG_ENTRY, label=EntityFilterSource.GET_FROM_CONFIG_ENTRY
|
||||
),
|
||||
SelectOptionDict(value=EntityFilterSource.LABEL, label=EntityFilterSource.LABEL),
|
||||
SelectOptionDict(value=EntityFilterSource.YAML, label=EntityFilterSource.YAML),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BaseFlowHandler(FlowHandler["ConfigFlowContext", ConfigFlowResult]):
|
||||
"""Handle shared steps between config and options flow for Yandex Smart Home."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a flow handler."""
|
||||
self._options: ConfigType = {}
|
||||
self._data: ConfigType = {}
|
||||
self._entry: ConfigEntry | None = None
|
||||
|
||||
super().__init__()
|
||||
|
||||
async def _async_step_skill_direct(
|
||||
self, platform: SmartHomePlatform, user_input: ConfigType | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose skill settings for direct connection."""
|
||||
errors = {}
|
||||
description_placeholders = {"external_url": self._get_external_url()}
|
||||
entry_skill = self._options.get(CONF_SKILL, {})
|
||||
|
||||
if DOMAIN not in self.hass.data:
|
||||
await async_setup_component(self.hass, DOMAIN, {}) # expose http endpoints for skill validation
|
||||
|
||||
if user_input is not None:
|
||||
if existed_entry := self._get_direct_connection_entry(
|
||||
platform=platform,
|
||||
user_id=user_input[CONF_USER_ID],
|
||||
):
|
||||
description_placeholders["entry_title"] = existed_entry.title
|
||||
errors["base"] = "already_configured"
|
||||
else:
|
||||
self._options[CONF_SKILL] = user_input
|
||||
|
||||
if self._entry:
|
||||
if user_input[CONF_ID] != entry_skill.get(CONF_ID) or user_input[CONF_USER_ID] != entry_skill.get(
|
||||
CONF_USER_ID
|
||||
):
|
||||
self._data[CONF_LINKED_PLATFORMS] = []
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._entry,
|
||||
title=await async_config_entry_title(self.hass, self._data, self._options),
|
||||
data=self._data,
|
||||
)
|
||||
|
||||
return await self.async_step_done()
|
||||
|
||||
return await self.async_step_expose_settings()
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USER_ID, default=entry_skill.get(CONF_USER_ID)): await _async_get_user_selector(
|
||||
self.hass, mode=SelectSelectorMode.DROPDOWN, required=True
|
||||
),
|
||||
vol.Required(CONF_ID, default=entry_skill.get(CONF_ID)): TextSelector(),
|
||||
vol.Required(CONF_TOKEN, default=entry_skill.get(CONF_TOKEN)): TextSelector(),
|
||||
},
|
||||
)
|
||||
|
||||
if platform == SmartHomePlatform.VK:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USER_ID, default=entry_skill.get(CONF_USER_ID)): await _async_get_user_selector(
|
||||
self.hass, mode=SelectSelectorMode.DROPDOWN, required=True
|
||||
),
|
||||
vol.Required(CONF_ID, default=entry_skill.get(CONF_ID)): TextSelector(),
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=f"skill_{platform}_direct",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_skill_yandex_direct(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose skill settings for direct connection to the Yandex Smart Home platform."""
|
||||
return await self._async_step_skill_direct(SmartHomePlatform.YANDEX, user_input)
|
||||
|
||||
async def async_step_skill_vk_direct(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose skill settings for direct connection to the VK Smart Home platform."""
|
||||
return await self._async_step_skill_direct(SmartHomePlatform.VK, user_input)
|
||||
|
||||
async def _async_step_skill_cloud_plus(
|
||||
self, platform: SmartHomePlatform, user_input: ConfigType | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose skill settings for cloud plus connection."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders = {
|
||||
"cloud_base_url": CLOUD_BASE_URL,
|
||||
"instance_id": self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
|
||||
}
|
||||
entry_skill = self._options.get(CONF_SKILL, {})
|
||||
|
||||
if user_input is not None:
|
||||
self._options[CONF_SKILL] = user_input
|
||||
|
||||
if self._entry:
|
||||
if user_input[CONF_ID] != entry_skill.get(CONF_ID):
|
||||
self._data[CONF_LINKED_PLATFORMS] = []
|
||||
|
||||
if user_input[CONF_ID] != entry_skill.get(CONF_ID) or user_input[CONF_NAME] != entry_skill.get(
|
||||
CONF_NAME
|
||||
):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._entry,
|
||||
title=await async_config_entry_title(self.hass, self._data, self._options),
|
||||
data=self._data,
|
||||
)
|
||||
|
||||
return await self.async_step_done()
|
||||
|
||||
return await self.async_step_expose_settings()
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default=entry_skill.get(CONF_NAME)): TextSelector(),
|
||||
vol.Required(CONF_ID, default=entry_skill.get(CONF_ID)): TextSelector(),
|
||||
vol.Required(CONF_TOKEN, default=entry_skill.get(CONF_TOKEN)): TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
if platform == SmartHomePlatform.VK:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default=entry_skill.get(CONF_NAME)): TextSelector(),
|
||||
vol.Required(CONF_ID, default=entry_skill.get(CONF_ID)): TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=f"skill_{platform}_cloud_plus",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_skill_yandex_cloud_plus(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose skill settings for cloud plus connection to the Yandex Smart Home platform."""
|
||||
return await self._async_step_skill_cloud_plus(SmartHomePlatform.YANDEX, user_input)
|
||||
|
||||
async def async_step_skill_vk_cloud_plus(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose skill settings for cloud plus connection to the VK Smart Home platform."""
|
||||
return await self._async_step_skill_cloud_plus(SmartHomePlatform.VK, user_input)
|
||||
|
||||
async def async_step_expose_settings(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose entity expose settings."""
|
||||
if user_input is not None:
|
||||
self._options.update(user_input)
|
||||
|
||||
match user_input[CONF_FILTER_SOURCE]:
|
||||
case EntityFilterSource.CONFIG_ENTRY:
|
||||
return await self.async_step_include_entities()
|
||||
case EntityFilterSource.GET_FROM_CONFIG_ENTRY:
|
||||
return await self.async_step_update_filter()
|
||||
case EntityFilterSource.LABEL:
|
||||
return await self.async_step_choose_label()
|
||||
|
||||
return await self.async_step_done()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="expose_settings",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_FILTER_SOURCE,
|
||||
default=self._options.get(CONF_FILTER_SOURCE, EntityFilterSource.CONFIG_ENTRY),
|
||||
): FILTER_SOURCE_SELECTOR,
|
||||
vol.Required(
|
||||
CONF_ENTRY_ALIASES,
|
||||
default=self._options.get(CONF_ENTRY_ALIASES, True),
|
||||
): BooleanSelector(),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_update_filter(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose a config entry from which the filter will be copied."""
|
||||
if user_input is not None:
|
||||
if user_input.get(CONF_FILTER_SOURCE) is True:
|
||||
return await self.async_step_expose_settings()
|
||||
|
||||
if entry := self.hass.config_entries.async_get_entry(user_input.get(CONF_ID, "")):
|
||||
self._options.update(
|
||||
{
|
||||
CONF_FILTER_SOURCE: EntityFilterSource.CONFIG_ENTRY,
|
||||
CONF_FILTER: entry.options[CONF_FILTER],
|
||||
}
|
||||
)
|
||||
|
||||
return await self.async_step_include_entities()
|
||||
|
||||
config_entries = [
|
||||
entry
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN)
|
||||
if CONF_FILTER in entry.options and (not self._entry or self._entry.entry_id != entry.entry_id)
|
||||
]
|
||||
if not config_entries:
|
||||
data_schema = None
|
||||
if not self._entry:
|
||||
data_schema = vol.Schema({vol.Optional(CONF_FILTER_SOURCE): BooleanSelector()})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="update_filter",
|
||||
data_schema=data_schema,
|
||||
errors={"base": "missing_config_entry"},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="update_filter",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.LIST,
|
||||
options=[
|
||||
SelectOptionDict(value=entry.entry_id, label=entry.title) for entry in config_entries
|
||||
],
|
||||
),
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_include_entities(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose entities that should be exposed."""
|
||||
errors = {}
|
||||
entities: set[str] = set()
|
||||
|
||||
if entity_filter_config := self._options.get(CONF_FILTER):
|
||||
entities.update(entity_filter_config.get(CONF_INCLUDE_ENTITIES, []))
|
||||
|
||||
if len(entity_filter_config) > 1 or CONF_INCLUDE_ENTITIES not in entity_filter_config:
|
||||
entity_filter: EntityFilter = FILTER_SCHEMA(entity_filter_config)
|
||||
if not entity_filter.empty_filter:
|
||||
entities.update([s.entity_id for s in self.hass.states.async_all() if entity_filter(s.entity_id)])
|
||||
|
||||
if user_input is not None:
|
||||
if user_input[CONF_ENTITIES]:
|
||||
self._options[CONF_FILTER] = {CONF_INCLUDE_ENTITIES: user_input[CONF_ENTITIES]}
|
||||
return await self.async_step_done()
|
||||
else:
|
||||
errors["base"] = "entities_not_selected"
|
||||
entities.clear()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="include_entities",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ENTITIES, default=sorted(entities)): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(multiple=True)
|
||||
)
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_choose_label(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose a label that should be used as filter for entities."""
|
||||
if user_input is not None:
|
||||
self._options.update(user_input)
|
||||
return await self.async_step_done()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="choose_label",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_LABEL, default=self._options.get(CONF_LABEL, "")): LabelSelector(
|
||||
LabelSelectorConfig(multiple=False),
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_done(self, _: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Finish the flow."""
|
||||
raise NotImplementedError
|
||||
|
||||
@callback
|
||||
def _get_direct_connection_entry(self, platform: SmartHomePlatform, user_id: str) -> ConfigEntry | None:
|
||||
"""Return already configured config entry with direct connection."""
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if self._entry and self._entry.entry_id == entry.entry_id:
|
||||
continue
|
||||
|
||||
if CONF_SKILL in entry.options:
|
||||
if (
|
||||
ConnectionType.DIRECT == entry.data[CONF_CONNECTION_TYPE]
|
||||
and platform == entry.data[CONF_PLATFORM]
|
||||
and user_id == entry.options[CONF_SKILL][CONF_USER_ID]
|
||||
):
|
||||
return entry
|
||||
|
||||
return None
|
||||
|
||||
def _get_external_url(self) -> str:
|
||||
"""Return external URL or abort the flow."""
|
||||
try:
|
||||
return network.get_url(self.hass, allow_internal=False)
|
||||
except network.NoURLAvailableError:
|
||||
raise AbortFlow("missing_external_url")
|
||||
|
||||
|
||||
class ConfigFlowHandler(BaseFlowHandler, ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Yandex Smart Home."""
|
||||
|
||||
VERSION = 6
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a config flow handler."""
|
||||
super().__init__()
|
||||
|
||||
self._data: ConfigType = {}
|
||||
|
||||
async def async_step_user(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_connection_type()
|
||||
|
||||
return self.async_show_form(step_id="user")
|
||||
|
||||
async def async_step_connection_type(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose connection type."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
self._data.update(user_input)
|
||||
|
||||
if user_input[CONF_CONNECTION_TYPE] == ConnectionType.CLOUD:
|
||||
try:
|
||||
instance = await cloud.register_instance(self.hass)
|
||||
self._data[CONF_CLOUD_INSTANCE] = {
|
||||
CONF_CLOUD_INSTANCE_ID: instance.id,
|
||||
CONF_CLOUD_INSTANCE_PASSWORD: instance.password,
|
||||
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN: instance.connection_token,
|
||||
}
|
||||
except (ClientConnectorError, ClientResponseError):
|
||||
errors["base"] = "cannot_connect"
|
||||
_LOGGER.exception("Failed to register instance in Yandex Smart Home cloud")
|
||||
|
||||
if not errors:
|
||||
match user_input[CONF_CONNECTION_TYPE]:
|
||||
case ConnectionType.DIRECT:
|
||||
return await self.async_step_platform_direct()
|
||||
case ConnectionType.CLOUD_PLUS:
|
||||
return await self.async_step_platform_cloud_plus()
|
||||
|
||||
return await self.async_step_expose_settings()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="connection_type",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_CONNECTION_TYPE, default=ConnectionType.CLOUD): CONNECTION_TYPE_SELECTOR}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_platform_direct(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose smart home platform for direct connection."""
|
||||
if user_input is not None:
|
||||
self._data.update(user_input)
|
||||
step_fn = getattr(self, f"async_step_skill_{self._data[CONF_PLATFORM]}_direct")
|
||||
return cast(ConfigFlowResult, await step_fn())
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="platform_direct",
|
||||
description_placeholders={"external_url": self._get_external_url()},
|
||||
data_schema=vol.Schema({vol.Required(CONF_PLATFORM): PLATFORM_SELECTOR}),
|
||||
)
|
||||
|
||||
async def async_step_platform_cloud_plus(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose smart home platform for cloud p connection."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
self._data.update(user_input)
|
||||
|
||||
try:
|
||||
instance = await cloud.register_instance(self.hass, SmartHomePlatform(user_input[CONF_PLATFORM]))
|
||||
self._data[CONF_CLOUD_INSTANCE] = {
|
||||
CONF_CLOUD_INSTANCE_ID: instance.id,
|
||||
CONF_CLOUD_INSTANCE_PASSWORD: instance.password,
|
||||
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN: instance.connection_token,
|
||||
}
|
||||
except (ClientConnectorError, ClientResponseError):
|
||||
errors["base"] = "cannot_connect"
|
||||
_LOGGER.exception("Failed to register instance in Yandex Smart Home cloud")
|
||||
|
||||
if not errors:
|
||||
step_fn = getattr(self, f"async_step_skill_{self._data[CONF_PLATFORM]}_cloud_plus")
|
||||
return cast(ConfigFlowResult, await step_fn())
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="platform_cloud_plus",
|
||||
data_schema=vol.Schema({vol.Required(CONF_PLATFORM): PLATFORM_SELECTOR}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_done(self, _: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Finish the flow."""
|
||||
description = self._data[CONF_CONNECTION_TYPE]
|
||||
description_placeholders: dict[str, str] = self._data.get(CONF_CLOUD_INSTANCE, {}).copy()
|
||||
|
||||
if self._data[CONF_CONNECTION_TYPE] in (ConnectionType.DIRECT, ConnectionType.CLOUD_PLUS):
|
||||
description += f"_{self._data[CONF_PLATFORM]}"
|
||||
|
||||
if self._data[CONF_CONNECTION_TYPE] == ConnectionType.CLOUD_PLUS:
|
||||
description_placeholders[CONF_SKILL] = self._options[CONF_SKILL][CONF_NAME]
|
||||
|
||||
if self._data[CONF_CONNECTION_TYPE] in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
|
||||
description_placeholders[CONF_CLOUD_INSTANCE_OTP] = "-"
|
||||
try:
|
||||
description_placeholders[CONF_CLOUD_INSTANCE_OTP] = await cloud.get_instance_otp(
|
||||
self.hass,
|
||||
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
|
||||
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN],
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to get one time password for cloud connection")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=await async_config_entry_title(self.hass, self._data, self._options),
|
||||
description=description,
|
||||
description_placeholders=description_placeholders,
|
||||
data=self._data,
|
||||
options=self._options,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlow, BaseFlowHandler):
|
||||
"""Handle a options flow for Yandex Smart Home."""
|
||||
|
||||
def __init__(self, entry: ConfigEntry):
|
||||
"""Initialize an options flow handler."""
|
||||
super().__init__()
|
||||
|
||||
self._entry: ConfigEntry = entry
|
||||
self._data: ConfigType = entry.data.copy()
|
||||
self._options: ConfigType = entry.options.copy()
|
||||
|
||||
async def async_step_init(self, _: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Show menu."""
|
||||
options = ["expose_settings"]
|
||||
match self._data[CONF_CONNECTION_TYPE]:
|
||||
case ConnectionType.CLOUD:
|
||||
options += ["cloud_credentials", "context_user"]
|
||||
case ConnectionType.CLOUD_PLUS:
|
||||
options += ["cloud_credentials", f"skill_{self._data[CONF_PLATFORM]}_cloud_plus", "context_user"]
|
||||
case ConnectionType.DIRECT:
|
||||
options += [f"skill_{self._data[CONF_PLATFORM]}_direct"]
|
||||
options += ["maintenance"]
|
||||
|
||||
return self.async_show_menu(step_id="init", menu_options=options)
|
||||
|
||||
async def async_step_cloud_credentials(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Show cloud connection credentials."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
return await self.async_step_init()
|
||||
|
||||
description_placeholders = {
|
||||
CONF_SKILL: "Yaha Cloud",
|
||||
CONF_CLOUD_INSTANCE_ID: self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
|
||||
CONF_CLOUD_INSTANCE_PASSWORD: self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_PASSWORD],
|
||||
CONF_CLOUD_INSTANCE_OTP: "-",
|
||||
}
|
||||
if self._data[CONF_CONNECTION_TYPE] == ConnectionType.CLOUD_PLUS:
|
||||
description_placeholders[CONF_SKILL] = self._options[CONF_SKILL][CONF_NAME]
|
||||
|
||||
try:
|
||||
description_placeholders[CONF_CLOUD_INSTANCE_OTP] = await cloud.get_instance_otp(
|
||||
self.hass,
|
||||
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
|
||||
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN],
|
||||
)
|
||||
except Exception:
|
||||
errors["base"] = "cannot_connect"
|
||||
_LOGGER.exception("Failed to get one time password for cloud connection")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="cloud_credentials", description_placeholders=description_placeholders, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_context_user(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Choose user for a service calls context."""
|
||||
if user_input is not None:
|
||||
if user_input[CONF_USER_ID] == USER_NONE:
|
||||
self._options.pop(CONF_USER_ID, None)
|
||||
else:
|
||||
self._options.update(user_input)
|
||||
|
||||
return await self.async_step_done()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="context_user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_USER_ID, default=self._options.get(CONF_USER_ID, USER_NONE)
|
||||
): await _async_get_user_selector(self.hass)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_maintenance(self, user_input: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Show maintenance actions."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders = {}
|
||||
|
||||
component: YandexSmartHome = self.hass.data[DOMAIN]
|
||||
entity_filter = component.get_entity_filter_from_yaml()
|
||||
|
||||
if user_input is not None:
|
||||
if user_input.get(MaintenanceAction.REVOKE_OAUTH_TOKENS):
|
||||
match self._data[CONF_CONNECTION_TYPE]:
|
||||
case ConnectionType.CLOUD:
|
||||
try:
|
||||
await cloud.revoke_oauth_tokens(
|
||||
self.hass,
|
||||
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
|
||||
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN],
|
||||
)
|
||||
except Exception as e:
|
||||
errors[MaintenanceAction.REVOKE_OAUTH_TOKENS] = "unknown"
|
||||
description_placeholders["error"] = str(e)
|
||||
|
||||
case ConnectionType.DIRECT:
|
||||
errors[MaintenanceAction.REVOKE_OAUTH_TOKENS] = "manual_revoke_oauth_tokens"
|
||||
|
||||
if user_input.get(MaintenanceAction.UNLINK_ALL_PLATFORMS):
|
||||
self._data[CONF_LINKED_PLATFORMS] = []
|
||||
self.hass.config_entries.async_update_entry(self._entry, data=self._data)
|
||||
|
||||
if user_input.get(MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN):
|
||||
try:
|
||||
instance = await cloud.reset_connection_token(
|
||||
self.hass,
|
||||
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID],
|
||||
self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN],
|
||||
)
|
||||
self._data[CONF_CLOUD_INSTANCE] = {
|
||||
CONF_CLOUD_INSTANCE_ID: instance.id,
|
||||
CONF_CLOUD_INSTANCE_PASSWORD: self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_PASSWORD],
|
||||
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN: instance.connection_token,
|
||||
}
|
||||
self.hass.config_entries.async_update_entry(self._entry, data=self._data)
|
||||
except Exception as e:
|
||||
errors[MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN] = "unknown"
|
||||
description_placeholders["error"] = str(e)
|
||||
|
||||
if user_input.get(MaintenanceAction.TRANSFER_ENTITY_FILTER_FROM_YAML):
|
||||
entity_ids: set[str] = set()
|
||||
|
||||
if entity_filter:
|
||||
for state in self.hass.states.async_all():
|
||||
if entity_filter(state.entity_id):
|
||||
entity_ids.add(state.entity_id)
|
||||
|
||||
match self._options[CONF_FILTER_SOURCE]:
|
||||
case EntityFilterSource.CONFIG_ENTRY:
|
||||
entity_ids.update(self._options[CONF_FILTER][CONF_INCLUDE_ENTITIES])
|
||||
self._options[CONF_FILTER] = {CONF_INCLUDE_ENTITIES: sorted(entity_ids)}
|
||||
|
||||
case EntityFilterSource.LABEL:
|
||||
for entity_id in entity_ids:
|
||||
registry = er.async_get(self.hass)
|
||||
if entity := registry.async_get(entity_id):
|
||||
registry.async_update_entity(
|
||||
entity.entity_id,
|
||||
labels=entity.labels | {self._options[CONF_LABEL]},
|
||||
)
|
||||
|
||||
if not errors:
|
||||
return await self.async_step_done()
|
||||
|
||||
actions = [MaintenanceAction.REVOKE_OAUTH_TOKENS, MaintenanceAction.UNLINK_ALL_PLATFORMS]
|
||||
if self._data[CONF_CONNECTION_TYPE] in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
|
||||
actions += [MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN]
|
||||
|
||||
if entity_filter and self._options[CONF_FILTER_SOURCE] in [
|
||||
EntityFilterSource.CONFIG_ENTRY,
|
||||
EntityFilterSource.LABEL,
|
||||
]:
|
||||
actions += [MaintenanceAction.TRANSFER_ENTITY_FILTER_FROM_YAML]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="maintenance",
|
||||
data_schema=vol.Schema({vol.Optional(action.value): BooleanSelector() for action in actions}),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_done(self, _: ConfigType | None = None) -> ConfigFlowResult:
|
||||
"""Finish the flow."""
|
||||
return self.async_create_entry(data=self._options)
|
||||
|
||||
|
||||
async def _async_get_user_selector(
|
||||
hass: HomeAssistant, mode: SelectSelectorMode = SelectSelectorMode.LIST, required: bool = False
|
||||
) -> SelectSelector:
|
||||
"""Return user selector."""
|
||||
users: list[SelectOptionDict] = []
|
||||
if not required:
|
||||
users.append(SelectOptionDict(value=USER_NONE, label=USER_NONE))
|
||||
|
||||
for user in await hass.auth.async_get_users():
|
||||
if any(gr.id == GROUP_ID_READ_ONLY for gr in user.groups):
|
||||
continue
|
||||
|
||||
users.append(SelectOptionDict(value=user.id, label=user.name or user.id))
|
||||
|
||||
return SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=mode,
|
||||
translation_key=CONF_USER_ID,
|
||||
options=users,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_config_entry_title(hass: HomeAssistant, data: ConfigType, options: ConfigType) -> str:
|
||||
"""Return config entry title."""
|
||||
if data.get(CONF_CONNECTION_TYPE) == ConnectionType.CLOUD:
|
||||
instance_id = data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID]
|
||||
return f"Yaha Cloud ({instance_id[:8]})"
|
||||
|
||||
title = DEFAULT_CONFIG_ENTRY_TITLE
|
||||
connection_type = ""
|
||||
match data.get(CONF_CONNECTION_TYPE):
|
||||
case ConnectionType.CLOUD_PLUS:
|
||||
connection_type = "Cloud Plus"
|
||||
case ConnectionType.DIRECT:
|
||||
connection_type = "Direct"
|
||||
|
||||
match data.get(CONF_PLATFORM):
|
||||
case SmartHomePlatform.YANDEX:
|
||||
title = f"Yandex Smart Home: {connection_type}"
|
||||
case SmartHomePlatform.VK:
|
||||
title = f"Marusia: {connection_type}"
|
||||
|
||||
if skill := options.get(CONF_SKILL):
|
||||
parts: list[str] = []
|
||||
if user := await hass.auth.async_get_user(skill.get(CONF_USER_ID, "")):
|
||||
parts.append(user.name or user.id[:6])
|
||||
if skill_id := skill.get(CONF_ID, ""):
|
||||
parts.append(skill_id[:8])
|
||||
if parts:
|
||||
title += f' ({" / ".join(parts)})'
|
||||
|
||||
return title
|
||||
@@ -0,0 +1,468 @@
|
||||
"""Helpers for config validation using voluptuous."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.event import EventDeviceClass
|
||||
from homeassistant.components.sensor.const import SensorDeviceClass
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_ROOM, CONF_STATE_TEMPLATE, CONF_TYPE
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.color import RGBColor
|
||||
import voluptuous as vol
|
||||
|
||||
from .color import ColorName, rgb_to_int
|
||||
from .const import (
|
||||
CONF_BACKLIGHT_ENTITY_ID,
|
||||
CONF_BETA,
|
||||
CONF_CLOUD_STREAM,
|
||||
CONF_COLOR_PROFILE,
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE,
|
||||
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID,
|
||||
CONF_ENTITY_CUSTOM_MODE_SET_MODE,
|
||||
CONF_ENTITY_CUSTOM_MODES,
|
||||
CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE,
|
||||
CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE,
|
||||
CONF_ENTITY_CUSTOM_RANGE_SET_VALUE,
|
||||
CONF_ENTITY_CUSTOM_RANGES,
|
||||
CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF,
|
||||
CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON,
|
||||
CONF_ENTITY_CUSTOM_TOGGLES,
|
||||
CONF_ENTITY_EVENT_MAP,
|
||||
CONF_ENTITY_MODE_MAP,
|
||||
CONF_ENTITY_PROPERTIES,
|
||||
CONF_ENTITY_PROPERTY_ATTRIBUTE,
|
||||
CONF_ENTITY_PROPERTY_ENTITY,
|
||||
CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT,
|
||||
CONF_ENTITY_PROPERTY_TYPE,
|
||||
CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT,
|
||||
CONF_ENTITY_PROPERTY_VALUE_TEMPLATE,
|
||||
CONF_ENTITY_RANGE,
|
||||
CONF_ENTITY_RANGE_MAX,
|
||||
CONF_ENTITY_RANGE_MIN,
|
||||
CONF_ENTITY_RANGE_PRECISION,
|
||||
CONF_ERROR_CODE_TEMPLATE,
|
||||
CONF_FEATURES,
|
||||
CONF_FILTER,
|
||||
CONF_NOTIFIER,
|
||||
CONF_NOTIFIER_OAUTH_TOKEN,
|
||||
CONF_NOTIFIER_SKILL_ID,
|
||||
CONF_NOTIFIER_USER_ID,
|
||||
CONF_PRESSURE_UNIT,
|
||||
CONF_SETTINGS,
|
||||
CONF_SLOW,
|
||||
CONF_STATE_UNKNOWN,
|
||||
CONF_SUPPORT_SET_CHANNEL,
|
||||
CONF_TURN_OFF,
|
||||
CONF_TURN_ON,
|
||||
MediaPlayerFeature,
|
||||
PropertyInstanceType,
|
||||
)
|
||||
from .schema import (
|
||||
ColorScene,
|
||||
ColorSettingCapabilityInstance,
|
||||
DeviceType,
|
||||
EventPropertyInstance,
|
||||
FloatPropertyInstance,
|
||||
ModeCapabilityInstance,
|
||||
ModeCapabilityMode,
|
||||
RangeCapabilityInstance,
|
||||
ToggleCapabilityInstance,
|
||||
)
|
||||
from .schema.property_event import get_supported_events_for_instance
|
||||
from .unit_conversion import UnitOfPressure, UnitOfTemperature
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def property_type(value: str) -> str:
|
||||
if value.startswith(f"{PropertyInstanceType.EVENT}."):
|
||||
instance = value.split(".", 1)[1]
|
||||
try:
|
||||
EventPropertyInstance(instance)
|
||||
return value
|
||||
except ValueError:
|
||||
raise vol.Invalid(
|
||||
f"Event property type '{instance}' is not supported, "
|
||||
f"see valid event types at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/event/#type"
|
||||
)
|
||||
|
||||
if value.startswith(f"{PropertyInstanceType.FLOAT}."):
|
||||
instance = value.split(".", 1)[1]
|
||||
try:
|
||||
FloatPropertyInstance(instance)
|
||||
return value
|
||||
except ValueError:
|
||||
raise vol.Invalid(
|
||||
f"Float property type '{instance}' is not supported, "
|
||||
f"see valid float types at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/float/#type"
|
||||
)
|
||||
|
||||
for enum in [FloatPropertyInstance, EventPropertyInstance]:
|
||||
with suppress(ValueError):
|
||||
return enum(value).value
|
||||
|
||||
device_class_to_float_instance = {
|
||||
SensorDeviceClass.ATMOSPHERIC_PRESSURE.value: FloatPropertyInstance.PRESSURE,
|
||||
SensorDeviceClass.CO2.value: FloatPropertyInstance.CO2_LEVEL,
|
||||
SensorDeviceClass.CURRENT.value: FloatPropertyInstance.AMPERAGE,
|
||||
SensorDeviceClass.ILLUMINANCE.value: FloatPropertyInstance.ILLUMINATION,
|
||||
SensorDeviceClass.PM1.value: FloatPropertyInstance.PM1_DENSITY,
|
||||
SensorDeviceClass.PM10.value: FloatPropertyInstance.PM10_DENSITY,
|
||||
SensorDeviceClass.PM25.value: FloatPropertyInstance.PM2_5_DENSITY,
|
||||
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS.value: FloatPropertyInstance.TVOC,
|
||||
}
|
||||
with suppress(KeyError):
|
||||
instance = device_class_to_float_instance[value]
|
||||
return f"{PropertyInstanceType.FLOAT}.{instance}"
|
||||
|
||||
raise vol.Invalid(
|
||||
f"Property type '{value}' is not supported, "
|
||||
f"see valid types at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/event/#type and "
|
||||
f"https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/float/#type"
|
||||
)
|
||||
|
||||
|
||||
def property_attributes(value: ConfigType) -> ConfigType:
|
||||
"""Validate keys for property."""
|
||||
entity = value.get(CONF_ENTITY_PROPERTY_ENTITY)
|
||||
attribute = value.get(CONF_ENTITY_PROPERTY_ATTRIBUTE)
|
||||
value_template = value.get(CONF_ENTITY_PROPERTY_VALUE_TEMPLATE)
|
||||
|
||||
if value_template and (entity or attribute):
|
||||
raise vol.Invalid("entity/attribute and value_template are mutually exclusive")
|
||||
|
||||
property_type_value = value.get(CONF_ENTITY_PROPERTY_TYPE)
|
||||
target_unit_of_measurement = value.get(CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT)
|
||||
if target_unit_of_measurement:
|
||||
try:
|
||||
if property_type_value in [
|
||||
FloatPropertyInstance.TEMPERATURE,
|
||||
f"{PropertyInstanceType.FLOAT}.{FloatPropertyInstance.TEMPERATURE}",
|
||||
]:
|
||||
assert UnitOfTemperature(target_unit_of_measurement).as_property_unit
|
||||
elif property_type_value in [
|
||||
FloatPropertyInstance.PRESSURE,
|
||||
f"{PropertyInstanceType.FLOAT}.{FloatPropertyInstance.PRESSURE}",
|
||||
]:
|
||||
assert UnitOfPressure(target_unit_of_measurement).as_property_unit
|
||||
else:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise vol.Invalid(
|
||||
f"Target unit of measurement '{target_unit_of_measurement}' is not supported "
|
||||
f"for {property_type_value} property, see valid values "
|
||||
f"at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/float/#property-target-unit-of-measurement"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def mode_instance(value: str) -> str:
|
||||
if value == ColorSettingCapabilityInstance.SCENE:
|
||||
return value
|
||||
|
||||
try:
|
||||
ModeCapabilityInstance(value)
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
f"Mode instance '{value}' is not supported, "
|
||||
f"see valid modes at https://docs.yaha-cloud.ru/v1.0.x/advanced/capabilities/mode/#instance"
|
||||
)
|
||||
raise vol.Invalid(f"Mode instance '{value}' is not supported")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def mode(value: str) -> str:
|
||||
for enum in [ModeCapabilityMode, ColorScene]:
|
||||
try:
|
||||
enum(value)
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
_LOGGER.error(
|
||||
f"Mode '{value}' is not supported, "
|
||||
f"see valid modes at https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode-instance-modes.html and "
|
||||
f"https://docs.yaha-cloud.ru/v1.0.x/devices/light/#scene-list"
|
||||
)
|
||||
|
||||
raise vol.Invalid(f"Mode '{value}' is not supported")
|
||||
|
||||
|
||||
def event_instance(value: str) -> str:
|
||||
try:
|
||||
EventPropertyInstance(value)
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
f"Event instance '{value}' is not supported, "
|
||||
f"see valid event types at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/event/#event-types"
|
||||
)
|
||||
raise vol.Invalid(f"Event instance '{value}' is not supported")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def event_map(value: dict[str, dict[str, list[str]]]) -> dict[str, dict[str, list[str]]]:
|
||||
for instance, mapped_events in value.items():
|
||||
supported_events = get_supported_events_for_instance(EventPropertyInstance(instance))
|
||||
for event in mapped_events:
|
||||
if event not in supported_events:
|
||||
_LOGGER.error(
|
||||
f"Event '{event}' is not supported for '{instance}' event instance, "
|
||||
f"see valid event types at https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/event/#event-types"
|
||||
)
|
||||
raise vol.Invalid(f"Event '{event}' is not supported for '{instance}' event instance")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def toggle_instance(value: str) -> str:
|
||||
try:
|
||||
ToggleCapabilityInstance(value)
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
f"Toggle instance '{value}' is not supported, "
|
||||
f"see valid values at https://docs.yaha-cloud.ru/v1.0.x/advanced/capabilities/toggle/#instance"
|
||||
)
|
||||
raise vol.Invalid(f"Toggle instance '{value}' is not supported")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def range_instance(value: str) -> str:
|
||||
try:
|
||||
RangeCapabilityInstance(value)
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
f"Range instance '{value}' is not supported, "
|
||||
f"see valid values at https://docs.yaha-cloud.ru/v1.0.x/advanced/capabilities/range/#instance"
|
||||
)
|
||||
raise vol.Invalid(f"Range instance '{value}' is not supported")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def entity_features(value: list[str]) -> list[str]:
|
||||
for feature in value:
|
||||
try:
|
||||
MediaPlayerFeature(feature)
|
||||
except ValueError:
|
||||
raise vol.Invalid(f"Feature {feature} is not supported")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def device_type(value: str) -> str:
|
||||
if value in ("devices.types.fan", "fan"):
|
||||
_LOGGER.warning(
|
||||
f"Device type '{value}' is deprecated, use 'devices.types.ventilation.fan' or 'ventilation.fan' instead"
|
||||
)
|
||||
value = "devices.types.ventilation.fan"
|
||||
|
||||
try:
|
||||
return str(DeviceType(value))
|
||||
except ValueError:
|
||||
try:
|
||||
return DeviceType(f"devices.types.{value}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
_LOGGER.error(
|
||||
f"Device type '{value}' is not supported, "
|
||||
f"see valid device types at https://yandex.ru/dev/dialogs/smart-home/doc/concepts/device-types.html"
|
||||
)
|
||||
raise vol.Invalid(f"Device type '{value}' is not supported")
|
||||
|
||||
|
||||
def color_name(value: str) -> str:
|
||||
try:
|
||||
ColorName(value)
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
f"Color name '{value}' is not supported, "
|
||||
f"see valid values at https://docs.yaha-cloud.ru/v1.0.x/devices/light/#color-profile-config"
|
||||
)
|
||||
raise vol.Invalid(f"Color name '{value}' is not supported")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def color_value(value: list[Any] | int) -> int:
|
||||
if isinstance(value, (int, str)):
|
||||
return int(value)
|
||||
|
||||
if isinstance(value, list) and len(value) == 3:
|
||||
return rgb_to_int(RGBColor(*[int(v) for v in value]))
|
||||
|
||||
raise vol.Invalid(f"Invalid value: {value}")
|
||||
|
||||
|
||||
def custom_capability_state(value: ConfigType) -> ConfigType:
|
||||
"""Validate keys for custom capability."""
|
||||
state_entity_id = value.get(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID)
|
||||
state_attribute = value.get(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE)
|
||||
state_template = value.get(CONF_STATE_TEMPLATE)
|
||||
|
||||
if state_template and (state_entity_id or state_attribute):
|
||||
raise vol.Invalid("state_entity_id/state_attribute and state_template are mutually exclusive")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
ENTITY_PROPERTY_SCHEMA = vol.All(
|
||||
cv.has_at_least_one_key(
|
||||
CONF_ENTITY_PROPERTY_ENTITY,
|
||||
CONF_ENTITY_PROPERTY_ATTRIBUTE,
|
||||
CONF_ENTITY_PROPERTY_VALUE_TEMPLATE,
|
||||
),
|
||||
vol.All(
|
||||
{
|
||||
vol.Required(CONF_ENTITY_PROPERTY_TYPE): vol.Schema(vol.All(str, property_type)),
|
||||
vol.Optional(CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT): cv.string,
|
||||
vol.Optional(CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT): cv.string,
|
||||
vol.Optional(CONF_ENTITY_PROPERTY_ENTITY): cv.entity_id,
|
||||
vol.Optional(CONF_ENTITY_PROPERTY_ATTRIBUTE): cv.string,
|
||||
vol.Optional(CONF_ENTITY_PROPERTY_VALUE_TEMPLATE): cv.template,
|
||||
},
|
||||
property_attributes,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
ENTITY_MODE_MAP_SCHEMA = vol.Schema(
|
||||
{vol.All(cv.string, mode_instance): vol.Schema({vol.All(cv.string, mode): vol.All(cv.ensure_list, [cv.string])})}
|
||||
)
|
||||
|
||||
ENTITY_EVENT_MAP_SCHEMA = vol.Schema(
|
||||
{vol.All(cv.string, event_instance): vol.Schema({cv.string: vol.All(cv.ensure_list, [cv.string])})}
|
||||
)
|
||||
|
||||
|
||||
ENTITY_RANGE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ENTITY_RANGE_MAX): vol.All(vol.Coerce(float), vol.Range(min=-100.0, max=1000.0)),
|
||||
vol.Optional(CONF_ENTITY_RANGE_MIN): vol.All(vol.Coerce(float), vol.Range(min=-100.0, max=1000.0)),
|
||||
vol.Optional(CONF_ENTITY_RANGE_PRECISION): vol.All(vol.Coerce(float), vol.Range(min=-100.0, max=1000.0)),
|
||||
},
|
||||
)
|
||||
|
||||
ENTITY_CUSTOM_MODE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.All(cv.string, mode_instance): vol.Any(
|
||||
vol.All(
|
||||
{
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_MODE_SET_MODE): cv.SERVICE_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE): cv.string,
|
||||
vol.Optional(CONF_STATE_TEMPLATE): cv.template,
|
||||
},
|
||||
custom_capability_state,
|
||||
),
|
||||
cv.boolean,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ENTITY_CUSTOM_RANGE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.All(cv.string, range_instance): vol.Any(
|
||||
vol.All(
|
||||
{
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_RANGE_SET_VALUE): vol.Any(cv.SERVICE_SCHEMA),
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE): vol.Any(cv.SERVICE_SCHEMA),
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE): vol.Any(cv.SERVICE_SCHEMA),
|
||||
vol.Optional(CONF_ENTITY_RANGE): ENTITY_RANGE_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE): cv.string,
|
||||
vol.Optional(CONF_STATE_TEMPLATE): cv.template,
|
||||
},
|
||||
custom_capability_state,
|
||||
),
|
||||
cv.boolean,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
ENTITY_CUSTOM_TOGGLE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.All(cv.string, toggle_instance): vol.Any(
|
||||
vol.All(
|
||||
{
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON): cv.SERVICE_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF): cv.SERVICE_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE): cv.string,
|
||||
vol.Optional(CONF_STATE_TEMPLATE): cv.template,
|
||||
},
|
||||
custom_capability_state,
|
||||
),
|
||||
cv.boolean,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
ENTITY_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ROOM): cv.string,
|
||||
vol.Optional(CONF_TYPE): vol.All(cv.string, device_type),
|
||||
vol.Optional(CONF_STATE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_TURN_ON): vol.Any(cv.SERVICE_SCHEMA, cv.boolean),
|
||||
vol.Optional(CONF_TURN_OFF): vol.Any(cv.SERVICE_SCHEMA, cv.boolean),
|
||||
vol.Optional(CONF_DEVICE_CLASS): vol.In(EventDeviceClass.BUTTON),
|
||||
vol.Optional(CONF_FEATURES): vol.All(cv.ensure_list, entity_features),
|
||||
vol.Optional(CONF_ENTITY_PROPERTIES): [ENTITY_PROPERTY_SCHEMA],
|
||||
vol.Optional(CONF_SUPPORT_SET_CHANNEL): cv.boolean,
|
||||
vol.Optional(CONF_STATE_UNKNOWN): cv.boolean,
|
||||
vol.Optional(CONF_SLOW): cv.boolean,
|
||||
vol.Optional(CONF_BACKLIGHT_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_COLOR_PROFILE): cv.string,
|
||||
vol.Optional(CONF_ERROR_CODE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_ENTITY_RANGE): ENTITY_RANGE_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_MODE_MAP): ENTITY_MODE_MAP_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_EVENT_MAP): vol.All(ENTITY_EVENT_MAP_SCHEMA, event_map),
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_MODES): ENTITY_CUSTOM_MODE_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_TOGGLES): ENTITY_CUSTOM_TOGGLE_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_CUSTOM_RANGES): ENTITY_CUSTOM_RANGE_SCHEMA,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
NOTIFIER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NOTIFIER_OAUTH_TOKEN): cv.string,
|
||||
vol.Required(CONF_NOTIFIER_SKILL_ID): cv.string,
|
||||
vol.Required(CONF_NOTIFIER_USER_ID): cv.string,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
SETTINGS_SCHEMA = vol.All(
|
||||
cv.deprecated(CONF_PRESSURE_UNIT),
|
||||
{
|
||||
vol.Optional(CONF_PRESSURE_UNIT): cv.string,
|
||||
vol.Optional(CONF_BETA): cv.boolean,
|
||||
vol.Optional(CONF_CLOUD_STREAM): cv.boolean,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
YANDEX_SMART_HOME_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NOTIFIER): vol.All(cv.ensure_list, [NOTIFIER_SCHEMA]),
|
||||
vol.Optional(CONF_SETTINGS): vol.All(lambda value: value or {}, SETTINGS_SCHEMA),
|
||||
vol.Optional(CONF_FILTER): BASE_FILTER_SCHEMA,
|
||||
vol.Optional(CONF_ENTITY_CONFIG): vol.All(lambda value: value or {}, {cv.entity_id: ENTITY_SCHEMA}),
|
||||
vol.Optional(CONF_COLOR_PROFILE): vol.Schema({cv.string: {vol.All(color_name): vol.All(color_value)}}),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Constants for Yandex Smart Home."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
DOMAIN = "yandex_smart_home"
|
||||
|
||||
CONF_SETTINGS = "settings"
|
||||
CONF_PRESSURE_UNIT = "pressure_unit"
|
||||
CONF_BETA = "beta"
|
||||
CONF_CLOUD_STREAM = "cloud_stream"
|
||||
CONF_CONNECTION_TYPE = "connection_type"
|
||||
CONF_CLOUD_INSTANCE = "cloud_instance"
|
||||
CONF_CLOUD_INSTANCE_ID = "id"
|
||||
CONF_CLOUD_INSTANCE_PASSWORD = "password"
|
||||
CONF_CLOUD_INSTANCE_OTP = "otp"
|
||||
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN = "token"
|
||||
CONF_USER_ID = "user_id"
|
||||
CONF_SKILL = "skill"
|
||||
CONF_COLOR_PROFILE = "color_profile"
|
||||
CONF_ENTITY_CONFIG = "entity_config"
|
||||
CONF_FILTER = "filter"
|
||||
CONF_FILTER_SOURCE = "filter_source"
|
||||
CONF_ENTRY_ALIASES = "entry_aliases"
|
||||
CONF_LABEL = "label"
|
||||
CONF_ADD_LABEL = "add_label"
|
||||
CONF_LINKED_PLATFORMS = "linked_platforms"
|
||||
CONF_TURN_ON = "turn_on"
|
||||
CONF_TURN_OFF = "turn_off"
|
||||
CONF_FEATURES = "features"
|
||||
CONF_SUPPORT_SET_CHANNEL = "support_set_channel"
|
||||
CONF_STATE_UNKNOWN = "state_unknown"
|
||||
CONF_BACKLIGHT_ENTITY_ID = "backlight_entity_id"
|
||||
CONF_ERROR_CODE_TEMPLATE = "error_code_template"
|
||||
CONF_SLOW = "slow"
|
||||
CONF_ENTITY_PROPERTY_TYPE = "type"
|
||||
CONF_ENTITY_PROPERTY_ENTITY = "entity"
|
||||
CONF_ENTITY_PROPERTY_ATTRIBUTE = "attribute"
|
||||
CONF_ENTITY_PROPERTY_VALUE_TEMPLATE = "value_template"
|
||||
CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT = "unit_of_measurement"
|
||||
CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT = "target_unit_of_measurement"
|
||||
CONF_ENTITY_PROPERTIES = "properties"
|
||||
CONF_ENTITY_RANGE = "range"
|
||||
CONF_ENTITY_RANGE_MIN = "min"
|
||||
CONF_ENTITY_RANGE_MAX = "max"
|
||||
CONF_ENTITY_RANGE_PRECISION = "precision"
|
||||
CONF_ENTITY_MODE_MAP = "modes"
|
||||
CONF_ENTITY_EVENT_MAP = "events"
|
||||
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID = "state_entity_id"
|
||||
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ATTRIBUTE = "state_attribute"
|
||||
CONF_ENTITY_CUSTOM_MODES = "custom_modes"
|
||||
CONF_ENTITY_CUSTOM_MODE_SET_MODE = "set_mode"
|
||||
CONF_ENTITY_CUSTOM_TOGGLES = "custom_toggles"
|
||||
CONF_ENTITY_CUSTOM_TOGGLE_TURN_ON = "turn_on"
|
||||
CONF_ENTITY_CUSTOM_TOGGLE_TURN_OFF = "turn_off"
|
||||
CONF_ENTITY_CUSTOM_RANGES = "custom_ranges"
|
||||
CONF_ENTITY_CUSTOM_RANGE_SET_VALUE = "set_value"
|
||||
CONF_ENTITY_CUSTOM_RANGE_INCREASE_VALUE = "increase_value"
|
||||
CONF_ENTITY_CUSTOM_RANGE_DECREASE_VALUE = "decrease_value"
|
||||
|
||||
ISSUE_ID_DEPRECATED_PRESSURE_UNIT = "deprecated_pressure_unit"
|
||||
ISSUE_ID_DEPRECATED_YAML_NOTIFIER = "deprecated_yaml_notifier"
|
||||
ISSUE_ID_DEPRECATED_YAML_SEVERAL_NOTIFIERS = "deprecated_yaml_several_notifiers"
|
||||
ISSUE_ID_MISSING_INTEGRATION = "missing_integration"
|
||||
ISSUE_ID_MISSING_SKILL_DATA = "missing_skill_data"
|
||||
ISSUE_ID_RECONNECTING_TOO_FAST = "reconnecting_too_fast"
|
||||
ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND = "unexposed_entity_found_"
|
||||
|
||||
# Legacy
|
||||
CONF_DEVICES_DISCOVERED = "devices_discovered"
|
||||
CONF_NOTIFIER = "notifier"
|
||||
CONF_NOTIFIER_OAUTH_TOKEN = "oauth_token"
|
||||
CONF_NOTIFIER_SKILL_ID = "skill_id"
|
||||
CONF_NOTIFIER_USER_ID = "user_id"
|
||||
|
||||
CLOUD_BASE_URL = "https://yaha-cloud.ru"
|
||||
CLOUD_STREAM_BASE_URL = "https://stream.yaha-cloud.ru"
|
||||
|
||||
EVENT_DEVICE_ACTION = "yandex_smart_home_device_action"
|
||||
ATTR_CAPABILITY = "capability"
|
||||
ATTR_ERROR_CODE = "error_code"
|
||||
|
||||
# Additional states
|
||||
STATE_NONE = "none"
|
||||
STATE_NONE_UI = "-"
|
||||
STATE_EMPTY = ""
|
||||
STATE_CHARGING = "charging"
|
||||
STATE_LOW = "low"
|
||||
|
||||
# Additional attributes
|
||||
ATTR_CURRENT = "current"
|
||||
ATTR_ILLUMINANCE = "illuminance"
|
||||
ATTR_LOAD_POWER = "load_power"
|
||||
ATTR_CURRENT_CONSUMPTION = "current_consumption"
|
||||
ATTR_POWER = "power"
|
||||
ATTR_TVOC = "total_volatile_organic_compounds"
|
||||
ATTR_WATER_LEVEL = "water_level"
|
||||
|
||||
# Custom component Xiaomi Gateway 3
|
||||
ATTR_ACTION = "action"
|
||||
|
||||
# Integration xiaomi_airpurifier
|
||||
ATTR_TARGET_HUMIDITY = "target_humidity"
|
||||
DOMAIN_XIAOMI_AIRPURIFIER = "xiaomi_miio_airpurifier"
|
||||
MODEL_PREFIX_XIAOMI_AIRPURIFIER = "zhimi."
|
||||
SERVICE_FAN_SET_TARGET_HUMIDITY = "fan_set_target_humidity"
|
||||
|
||||
# https://github.com/ClusterM/skykettle-ha/blob/c1b61c4a22693d6e2b7c2f57a989df418011f2c2/custom_components/skykettle/skykettle.py#L53
|
||||
SKYKETTLE_MODE_BOIL = "Boil"
|
||||
|
||||
type EntityId = str
|
||||
|
||||
|
||||
class ConnectionType(StrEnum):
|
||||
"""Valid connection type."""
|
||||
|
||||
DIRECT = "direct"
|
||||
CLOUD = "cloud"
|
||||
CLOUD_PLUS = "cloud_plus"
|
||||
|
||||
|
||||
class EntityFilterSource(StrEnum):
|
||||
"""Possible sources for entity filter."""
|
||||
|
||||
CONFIG_ENTRY = "config_entry"
|
||||
GET_FROM_CONFIG_ENTRY = "get_from_config_entry"
|
||||
YAML = "yaml"
|
||||
LABEL = "label"
|
||||
|
||||
|
||||
class MediaPlayerFeature(StrEnum):
|
||||
"""Media player feature that user can force enable."""
|
||||
|
||||
VOLUME_MUTE = "volume_mute"
|
||||
VOLUME_SET = "volume_set"
|
||||
NEXT_PREVIOUS_TRACK = "next_previous_track"
|
||||
SELECT_SOURCE = "select_source"
|
||||
TURN_ON_OFF = "turn_on_off"
|
||||
PLAY_PAUSE = "play_pause"
|
||||
PLAY_MEDIA = "play_media"
|
||||
|
||||
|
||||
class PropertyInstanceType(StrEnum):
|
||||
"""Property instance type for config validation."""
|
||||
|
||||
FLOAT = "float"
|
||||
EVENT = "event"
|
||||
|
||||
|
||||
class XGW3DeviceClass(StrEnum):
|
||||
"""Device class for Xiaomi Gateway 3 custom component."""
|
||||
|
||||
ACTION = "action"
|
||||
TVOC = "tvoc"
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Yandex Smart Home user device."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components import (
|
||||
air_quality,
|
||||
automation,
|
||||
binary_sensor,
|
||||
button,
|
||||
camera,
|
||||
climate,
|
||||
cover,
|
||||
event,
|
||||
fan,
|
||||
group,
|
||||
humidifier,
|
||||
input_boolean,
|
||||
input_button,
|
||||
input_text,
|
||||
light,
|
||||
lock,
|
||||
media_player,
|
||||
remote,
|
||||
scene,
|
||||
script,
|
||||
sensor,
|
||||
switch,
|
||||
vacuum,
|
||||
valve,
|
||||
water_heater,
|
||||
)
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.cover import CoverDeviceClass
|
||||
from homeassistant.components.event import EventDeviceClass
|
||||
from homeassistant.components.media_player import MediaPlayerDeviceClass
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.components.switch import SwitchDeviceClass
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_NAME,
|
||||
CONF_ROOM,
|
||||
CONF_STATE_TEMPLATE,
|
||||
CONF_TYPE,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, State, callback
|
||||
from homeassistant.helpers.area_registry import AreaEntry
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.template import Template
|
||||
|
||||
from . import ( # noqa: F401
|
||||
capability_color,
|
||||
capability_custom,
|
||||
capability_mode,
|
||||
capability_onoff,
|
||||
capability_range,
|
||||
capability_toggle,
|
||||
capability_video,
|
||||
property_custom,
|
||||
property_event,
|
||||
property_float,
|
||||
)
|
||||
from .capability import STATE_CAPABILITIES_REGISTRY, Capability, DummyCapability, StateCapability
|
||||
from .capability_custom import get_custom_capability
|
||||
from .capability_toggle import BacklightCapability
|
||||
from .const import (
|
||||
CONF_BACKLIGHT_ENTITY_ID,
|
||||
CONF_ENTITY_CUSTOM_MODES,
|
||||
CONF_ENTITY_CUSTOM_RANGES,
|
||||
CONF_ENTITY_CUSTOM_TOGGLES,
|
||||
CONF_ENTITY_PROPERTIES,
|
||||
CONF_ERROR_CODE_TEMPLATE,
|
||||
)
|
||||
from .helpers import ActionNotAllowed, APIError, _get_registry_entries
|
||||
from .property import STATE_PROPERTIES_REGISTRY, Property, StateProperty
|
||||
from .property_custom import get_custom_property, get_event_platform_custom_property_type
|
||||
from .schema import (
|
||||
CapabilityDescription,
|
||||
CapabilityInstanceAction,
|
||||
CapabilityInstanceActionResultValue,
|
||||
CapabilityInstanceState,
|
||||
CapabilityType,
|
||||
DeviceDescription,
|
||||
DeviceInfo,
|
||||
DeviceState,
|
||||
DeviceType,
|
||||
OnOffCapabilityInstance,
|
||||
PropertyDescription,
|
||||
PropertyInstanceState,
|
||||
ResponseCode,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entry_data import ConfigEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DOMAIN_TO_DEVICE_TYPES: dict[str, DeviceType] = {
|
||||
air_quality.DOMAIN: DeviceType.SENSOR,
|
||||
automation.DOMAIN: DeviceType.OTHER,
|
||||
binary_sensor.DOMAIN: DeviceType.SENSOR,
|
||||
button.DOMAIN: DeviceType.OTHER,
|
||||
camera.DOMAIN: DeviceType.CAMERA,
|
||||
climate.DOMAIN: DeviceType.THERMOSTAT,
|
||||
cover.DOMAIN: DeviceType.OPENABLE,
|
||||
event.DOMAIN: DeviceType.SENSOR,
|
||||
fan.DOMAIN: DeviceType.VENTILATION_FAN,
|
||||
group.DOMAIN: DeviceType.SWITCH,
|
||||
humidifier.DOMAIN: DeviceType.HUMIDIFIER,
|
||||
input_boolean.DOMAIN: DeviceType.SWITCH,
|
||||
input_button.DOMAIN: DeviceType.OTHER,
|
||||
input_text.DOMAIN: DeviceType.SENSOR,
|
||||
light.DOMAIN: DeviceType.LIGHT,
|
||||
lock.DOMAIN: DeviceType.OPENABLE,
|
||||
media_player.DOMAIN: DeviceType.MEDIA_DEVICE,
|
||||
remote.DOMAIN: DeviceType.SWITCH,
|
||||
scene.DOMAIN: DeviceType.OTHER,
|
||||
script.DOMAIN: DeviceType.OTHER,
|
||||
sensor.DOMAIN: DeviceType.SENSOR,
|
||||
switch.DOMAIN: DeviceType.SWITCH,
|
||||
vacuum.DOMAIN: DeviceType.VACUUM_CLEANER,
|
||||
valve.DOMAIN: DeviceType.OPENABLE_VALVE,
|
||||
water_heater.DOMAIN: DeviceType.KETTLE,
|
||||
}
|
||||
|
||||
_DEVICE_CLASS_TO_DEVICE_TYPES: dict[tuple[str, str], DeviceType] = {
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.DOOR): DeviceType.SENSOR_OPEN,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.GARAGE_DOOR): DeviceType.SENSOR_OPEN,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.GAS): DeviceType.SENSOR_GAS,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.MOISTURE): DeviceType.SENSOR_WATER_LEAK,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.MOTION): DeviceType.SENSOR_MOTION,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.MOVING): DeviceType.SENSOR_MOTION,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.OCCUPANCY): DeviceType.SENSOR_MOTION,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.OPENING): DeviceType.SENSOR_OPEN,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.PRESENCE): DeviceType.SENSOR_MOTION,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.SMOKE): DeviceType.SENSOR_SMOKE,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.VIBRATION): DeviceType.SENSOR_VIBRATION,
|
||||
(binary_sensor.DOMAIN, BinarySensorDeviceClass.WINDOW): DeviceType.SENSOR_OPEN,
|
||||
(cover.DOMAIN, CoverDeviceClass.CURTAIN): DeviceType.OPENABLE_CURTAIN,
|
||||
(media_player.DOMAIN, MediaPlayerDeviceClass.RECEIVER): DeviceType.MEDIA_DEVICE_RECIEVER,
|
||||
(media_player.DOMAIN, MediaPlayerDeviceClass.TV): DeviceType.MEDIA_DEVICE_TV,
|
||||
(sensor.DOMAIN, EventDeviceClass.BUTTON): DeviceType.SENSOR_BUTTON,
|
||||
(sensor.DOMAIN, SensorDeviceClass.CO): DeviceType.SENSOR_CLIMATE,
|
||||
(sensor.DOMAIN, SensorDeviceClass.CO2): DeviceType.SENSOR_CLIMATE,
|
||||
(sensor.DOMAIN, SensorDeviceClass.ENERGY): DeviceType.SMART_METER_ELECTRICITY,
|
||||
(sensor.DOMAIN, SensorDeviceClass.GAS): DeviceType.SMART_METER_GAS,
|
||||
(sensor.DOMAIN, SensorDeviceClass.HUMIDITY): DeviceType.SENSOR_CLIMATE,
|
||||
(sensor.DOMAIN, SensorDeviceClass.ILLUMINANCE): DeviceType.SENSOR_ILLUMINATION,
|
||||
(sensor.DOMAIN, SensorDeviceClass.PM1): DeviceType.SENSOR_CLIMATE,
|
||||
(sensor.DOMAIN, SensorDeviceClass.PM10): DeviceType.SENSOR_CLIMATE,
|
||||
(sensor.DOMAIN, SensorDeviceClass.PM25): DeviceType.SENSOR_CLIMATE,
|
||||
(sensor.DOMAIN, SensorDeviceClass.PRESSURE): DeviceType.SENSOR_CLIMATE,
|
||||
(sensor.DOMAIN, SensorDeviceClass.TEMPERATURE): DeviceType.SENSOR_CLIMATE,
|
||||
(sensor.DOMAIN, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS): DeviceType.SENSOR_CLIMATE,
|
||||
(sensor.DOMAIN, SensorDeviceClass.WATER): DeviceType.SMART_METER_COLD_WATER,
|
||||
(switch.DOMAIN, SwitchDeviceClass.OUTLET): DeviceType.SOCKET,
|
||||
(event.DOMAIN, EventDeviceClass.BUTTON): DeviceType.SENSOR_BUTTON,
|
||||
(event.DOMAIN, EventDeviceClass.DOORBELL): DeviceType.SENSOR_BUTTON,
|
||||
(event.DOMAIN, EventDeviceClass.MOTION): DeviceType.SENSOR_MOTION,
|
||||
}
|
||||
|
||||
type DeviceId = str
|
||||
|
||||
|
||||
class Device:
|
||||
"""Represent user device."""
|
||||
|
||||
__slots__ = ("_hass", "_entry_data", "_state", "_config", "id")
|
||||
|
||||
id: str
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData, device_id: str, state: State | None):
|
||||
"""Initialize a device for the state."""
|
||||
self.id = device_id
|
||||
|
||||
self._hass = hass
|
||||
self._entry_data = entry_data
|
||||
self._state = state or State(entity_id=device_id, state=STATE_UNAVAILABLE)
|
||||
self._config = self._entry_data.get_entity_config(self.id)
|
||||
|
||||
@callback
|
||||
def get_capabilities(self) -> list[Capability[Any]]:
|
||||
"""Return all capabilities of the device."""
|
||||
capabilities: list[Capability[Any]] = []
|
||||
disabled_capabilities: list[Capability[Any]] = []
|
||||
|
||||
def _append_capabilities(_capability: Capability[Any]) -> None:
|
||||
if _capability.supported and _capability not in capabilities and _capability not in disabled_capabilities:
|
||||
capabilities.append(_capability)
|
||||
|
||||
if (state_template := self._config.get(CONF_STATE_TEMPLATE)) is not None:
|
||||
capabilities.append(
|
||||
get_custom_capability(
|
||||
self._hass,
|
||||
self._entry_data,
|
||||
{CONF_STATE_TEMPLATE: state_template},
|
||||
CapabilityType.ON_OFF,
|
||||
OnOffCapabilityInstance.ON,
|
||||
self.id,
|
||||
)
|
||||
)
|
||||
|
||||
for capability_type, config_key in (
|
||||
(CapabilityType.MODE, CONF_ENTITY_CUSTOM_MODES),
|
||||
(CapabilityType.TOGGLE, CONF_ENTITY_CUSTOM_TOGGLES),
|
||||
(CapabilityType.RANGE, CONF_ENTITY_CUSTOM_RANGES),
|
||||
):
|
||||
if config_key in self._config:
|
||||
for instance in self._config[config_key]:
|
||||
capability_config = self._config[config_key][instance]
|
||||
match capability_config:
|
||||
case False:
|
||||
disabled_capabilities.append(
|
||||
DummyCapability(self._hass, self._entry_data, capability_type, instance, self.id)
|
||||
)
|
||||
case dict():
|
||||
custom_capability = get_custom_capability(
|
||||
self._hass,
|
||||
self._entry_data,
|
||||
capability_config,
|
||||
capability_type,
|
||||
instance,
|
||||
self.id,
|
||||
)
|
||||
|
||||
_append_capabilities(custom_capability)
|
||||
|
||||
for CapabilityT in STATE_CAPABILITIES_REGISTRY:
|
||||
state_capability = CapabilityT(self._hass, self._entry_data, self.id, self._state)
|
||||
_append_capabilities(state_capability)
|
||||
|
||||
if backlight_entity_id := self._config.get(CONF_BACKLIGHT_ENTITY_ID):
|
||||
backlight_state = self._hass.states.get(backlight_entity_id)
|
||||
if backlight_state and backlight_entity_id != self.id:
|
||||
backlight_device = Device(self._hass, self._entry_data, backlight_state.entity_id, backlight_state)
|
||||
for capability in backlight_device.get_capabilities():
|
||||
if capability.type != CapabilityType.ON_OFF:
|
||||
_append_capabilities(capability)
|
||||
|
||||
backlight_capability = BacklightCapability(self._hass, self._entry_data, self.id, backlight_state)
|
||||
_append_capabilities(backlight_capability)
|
||||
|
||||
return capabilities
|
||||
|
||||
@callback
|
||||
def get_state_capabilities(self) -> list[StateCapability[Any]]:
|
||||
"""Return capabilities of the device based on the state."""
|
||||
return [c for c in self.get_capabilities() if isinstance(c, StateCapability)]
|
||||
|
||||
@callback
|
||||
def get_properties(self) -> list[Property]:
|
||||
"""Return all properties for the device."""
|
||||
properties: list[Property] = []
|
||||
|
||||
for property_config in self._config.get(CONF_ENTITY_PROPERTIES, []):
|
||||
try:
|
||||
custom_property = get_custom_property(self._hass, self._entry_data, property_config, self.id)
|
||||
except APIError as e:
|
||||
_LOGGER.error(e)
|
||||
continue
|
||||
|
||||
if custom_property and custom_property.supported and custom_property not in properties:
|
||||
properties.append(custom_property)
|
||||
continue
|
||||
|
||||
if event_platform_property_type := get_event_platform_custom_property_type(property_config):
|
||||
event_platform_property = event_platform_property_type(
|
||||
self._hass, self._entry_data, self.id, State(self.id, STATE_UNKNOWN)
|
||||
)
|
||||
if event_platform_property.supported and event_platform_property not in properties:
|
||||
properties.append(event_platform_property)
|
||||
|
||||
for PropertyT in STATE_PROPERTIES_REGISTRY:
|
||||
device_property = PropertyT(self._hass, self._entry_data, self.id, self._state)
|
||||
if device_property.supported and device_property not in properties:
|
||||
properties.append(device_property)
|
||||
|
||||
return properties
|
||||
|
||||
@callback
|
||||
def get_state_properties(self) -> list[StateProperty]:
|
||||
"""Return properties for the device based on the state."""
|
||||
return [p for p in self.get_properties() if isinstance(p, StateProperty)]
|
||||
|
||||
@property
|
||||
def should_expose(self) -> bool:
|
||||
"""Test if the device should be exposed."""
|
||||
return self._entry_data.should_expose(self.id)
|
||||
|
||||
@property
|
||||
@callback
|
||||
def unavailable(self) -> bool:
|
||||
"""Test if the device is unavailable."""
|
||||
state_template: Template | None
|
||||
if (state_template := self._config.get(CONF_STATE_TEMPLATE)) is not None:
|
||||
return bool(state_template.async_render() == STATE_UNAVAILABLE)
|
||||
|
||||
return self._state.state == STATE_UNAVAILABLE
|
||||
|
||||
@property
|
||||
def type(self) -> DeviceType:
|
||||
"""Return device type."""
|
||||
if user_type := self._config.get(CONF_TYPE):
|
||||
return DeviceType(user_type)
|
||||
|
||||
domain = self._state.domain
|
||||
device_class: str = self._config.get(CONF_DEVICE_CLASS, self._state.attributes.get(ATTR_DEVICE_CLASS, ""))
|
||||
|
||||
if device_class_type := _DEVICE_CLASS_TO_DEVICE_TYPES.get((domain, device_class)):
|
||||
return device_class_type
|
||||
|
||||
if domain_type := _DOMAIN_TO_DEVICE_TYPES.get(domain):
|
||||
return domain_type
|
||||
|
||||
return DeviceType.OTHER
|
||||
|
||||
async def describe(self) -> DeviceDescription | None:
|
||||
"""Return description of the device."""
|
||||
capabilities: list[CapabilityDescription] = []
|
||||
for c in self.get_capabilities():
|
||||
if c_description := c.get_description():
|
||||
capabilities.append(c_description)
|
||||
|
||||
properties: list[PropertyDescription] = []
|
||||
for p in self.get_properties():
|
||||
if p_description := p.get_description():
|
||||
properties.append(p_description)
|
||||
|
||||
if not capabilities and not properties:
|
||||
return None
|
||||
|
||||
entity_entry, device_entry, area_entry = _get_registry_entries(self._hass, self.id)
|
||||
device_info = DeviceInfo(model=self.id)
|
||||
if device_entry is not None:
|
||||
if device_entry.model:
|
||||
device_model = f"{device_entry.model} | {self.id}"
|
||||
else:
|
||||
device_model = self.id
|
||||
|
||||
device_info = DeviceInfo(
|
||||
manufacturer=device_entry.manufacturer,
|
||||
model=device_model,
|
||||
sw_version=device_entry.sw_version,
|
||||
)
|
||||
|
||||
if (room := self._get_room(area_entry)) is not None:
|
||||
room = room.strip()
|
||||
|
||||
assert self.type
|
||||
return DeviceDescription(
|
||||
id=self.id,
|
||||
name=self._get_name(entity_entry).strip(),
|
||||
room=room,
|
||||
type=self.type,
|
||||
capabilities=capabilities or None,
|
||||
properties=properties or None,
|
||||
device_info=device_info,
|
||||
)
|
||||
|
||||
@callback
|
||||
def query(self) -> DeviceState:
|
||||
"""Return state of the device."""
|
||||
check_availability = True
|
||||
|
||||
if self.unavailable:
|
||||
return DeviceState(id=self.id, error_code=ResponseCode.DEVICE_UNREACHABLE)
|
||||
|
||||
capabilities: list[CapabilityInstanceState] = []
|
||||
for c in self.get_capabilities():
|
||||
if c.retrievable:
|
||||
try:
|
||||
if (capability_state := c.get_instance_state()) is not None:
|
||||
capabilities.append(capability_state)
|
||||
except APIError as e:
|
||||
_LOGGER.error(e)
|
||||
else:
|
||||
check_availability = False
|
||||
|
||||
properties: list[PropertyInstanceState] = []
|
||||
for p in self.get_properties():
|
||||
if p.retrievable:
|
||||
try:
|
||||
if (property_state := p.get_instance_state()) is not None:
|
||||
properties.append(property_state)
|
||||
except APIError as e:
|
||||
_LOGGER.error(e)
|
||||
else:
|
||||
check_availability = False
|
||||
|
||||
if check_availability and not capabilities and not properties:
|
||||
return DeviceState(id=self.id, error_code=ResponseCode.DEVICE_UNREACHABLE)
|
||||
|
||||
return DeviceState(
|
||||
id=self.id,
|
||||
capabilities=capabilities or None,
|
||||
properties=properties or None,
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self, context: Context, action: CapabilityInstanceAction
|
||||
) -> CapabilityInstanceActionResultValue | None:
|
||||
"""Execute an action to change capability state."""
|
||||
target_capability: Capability[Any] | None = None
|
||||
|
||||
for capability in self.get_capabilities():
|
||||
if capability.type == action.type and capability.instance == action.state.instance:
|
||||
target_capability = capability
|
||||
break
|
||||
|
||||
if not target_capability:
|
||||
raise APIError(
|
||||
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
f"Device {self.id} doesn't support instance {action.state.instance} of {action.type.short} capability",
|
||||
)
|
||||
|
||||
if error_code_template := self._error_code_template:
|
||||
if error_code := error_code_template.async_render(
|
||||
capability=action.as_dict(), entity_id=self.id, parse_result=False
|
||||
):
|
||||
try:
|
||||
code = ResponseCode(error_code)
|
||||
except ValueError:
|
||||
raise APIError(ResponseCode.INTERNAL_ERROR, f"Error code '{error_code}' is invalid for {self.id}")
|
||||
|
||||
raise ActionNotAllowed(code)
|
||||
|
||||
try:
|
||||
return await target_capability.set_instance_state(context, action.state)
|
||||
except (APIError, ActionNotAllowed):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise APIError(ResponseCode.INTERNAL_ERROR, f"Failed to execute action for {target_capability}: {e!r}")
|
||||
|
||||
def _get_name(self, entity_entry: RegistryEntry | None) -> str:
|
||||
"""Return the device name."""
|
||||
if name := self._config.get(CONF_NAME):
|
||||
return str(name)
|
||||
|
||||
if entity_entry:
|
||||
if alias := self._get_entry_alias(entity_entry.aliases):
|
||||
return alias
|
||||
|
||||
return self._state.name or self.id
|
||||
|
||||
def _get_room(self, area: AreaEntry | None) -> str | None:
|
||||
"""Return room of the device."""
|
||||
if room := self._config.get(CONF_ROOM):
|
||||
return str(room)
|
||||
|
||||
if area:
|
||||
if alias := self._get_entry_alias(area.aliases):
|
||||
return alias
|
||||
|
||||
return area.name
|
||||
|
||||
return None
|
||||
|
||||
def _get_entry_alias(self, aliases: set[str] | None) -> str | None:
|
||||
"""Return best matched entry alias."""
|
||||
filtered_aliases: set[str] = set()
|
||||
for alias in aliases or []:
|
||||
if "алиса:" in alias.lower():
|
||||
filtered_aliases.add(alias.split(":", 1)[1].strip())
|
||||
elif self._entry_data.use_entry_aliases and re.search(r"^[а-яё0-9 ]+$", alias, flags=re.IGNORECASE):
|
||||
filtered_aliases.add(alias)
|
||||
|
||||
if not filtered_aliases:
|
||||
return None
|
||||
|
||||
return sorted(filtered_aliases)[0]
|
||||
|
||||
@property
|
||||
def _error_code_template(self) -> Template | None:
|
||||
"""Prepare template for error code."""
|
||||
return self._config.get(CONF_ERROR_CODE_TEMPLATE)
|
||||
|
||||
|
||||
async def async_get_devices(hass: HomeAssistant, entry_data: ConfigEntryData) -> list[Device]:
|
||||
"""Return list of supported user devices."""
|
||||
devices: list[Device] = []
|
||||
|
||||
for state in hass.states.async_all():
|
||||
device = Device(hass, entry_data, state.entity_id, state)
|
||||
if device.should_expose and not device.unavailable:
|
||||
devices.append(device)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
async def async_get_device_description(hass: HomeAssistant, device: Device) -> DeviceDescription | None:
|
||||
"""Return description for a user device."""
|
||||
if (description := await device.describe()) is not None:
|
||||
return description
|
||||
|
||||
_LOGGER.debug(f"Missing capabilities and properties for {device.id}")
|
||||
return None
|
||||
|
||||
|
||||
async def async_get_device_states(
|
||||
hass: HomeAssistant, entry_data: ConfigEntryData, device_ids: list[str]
|
||||
) -> list[DeviceState]:
|
||||
"""Return list of the states of user devices."""
|
||||
states: list[DeviceState] = []
|
||||
|
||||
for device_id in device_ids:
|
||||
state = hass.states.get(device_id)
|
||||
device = Device(hass, entry_data, device_id, state)
|
||||
|
||||
if state and not device.should_expose:
|
||||
entry_data.mark_entity_unexposed(state.entity_id)
|
||||
|
||||
states.append(device.query())
|
||||
|
||||
return states
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Diagnostics support for Yandex Smart Home."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry
|
||||
|
||||
from . import DOMAIN, YandexSmartHome
|
||||
from .const import CONF_CLOUD_INSTANCE, CONF_SKILL
|
||||
from .device import async_get_device_description, async_get_devices
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(hass: HomeAssistant, config_entry: ConfigEntry) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
component: YandexSmartHome = hass.data[DOMAIN]
|
||||
entry_data = component.get_entry_data(config_entry)
|
||||
|
||||
diag: dict[str, Any] = {
|
||||
"entry": async_redact_data(config_entry.as_dict(), [CONF_CLOUD_INSTANCE, CONF_SKILL]),
|
||||
"devices": {},
|
||||
"issues": [i.to_json() for i in issue_registry.async_get(hass).issues.values() if i.domain == DOMAIN],
|
||||
}
|
||||
diag.update(component.get_diagnostics())
|
||||
|
||||
for device in await async_get_devices(hass, entry_data):
|
||||
diag["devices"][device.id] = {
|
||||
"capabilities": [c.__repr__() for c in device.get_capabilities()],
|
||||
"properties": [p.__repr__() for p in device.get_properties()],
|
||||
"description": await async_get_device_description(hass, device),
|
||||
"state": device.query(),
|
||||
}
|
||||
|
||||
return async_redact_data(diag, [])
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Config entry data for the Yandex Smart Home."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
import logging
|
||||
from typing import Any, Self, cast
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_STATE_TEMPLATE,
|
||||
CONF_TOKEN,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import CoreState, HomeAssistant, State
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
from homeassistant.helpers.entityfilter import EntityFilter
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_custom_components
|
||||
|
||||
from . import capability_custom, property_custom
|
||||
from .capability_custom import CustomCapability, get_custom_capability
|
||||
from .cloud import CloudManager
|
||||
from .color import ColorProfiles
|
||||
from .const import (
|
||||
CONF_BACKLIGHT_ENTITY_ID,
|
||||
CONF_CLOUD_INSTANCE,
|
||||
CONF_CLOUD_INSTANCE_CONNECTION_TOKEN,
|
||||
CONF_CLOUD_INSTANCE_ID,
|
||||
CONF_CLOUD_STREAM,
|
||||
CONF_COLOR_PROFILE,
|
||||
CONF_CONNECTION_TYPE,
|
||||
CONF_ENTITY_CUSTOM_MODES,
|
||||
CONF_ENTITY_CUSTOM_RANGES,
|
||||
CONF_ENTITY_CUSTOM_TOGGLES,
|
||||
CONF_ENTITY_PROPERTIES,
|
||||
CONF_ENTITY_PROPERTY_ENTITY,
|
||||
CONF_ENTRY_ALIASES,
|
||||
CONF_FILTER_SOURCE,
|
||||
CONF_LABEL,
|
||||
CONF_LINKED_PLATFORMS,
|
||||
CONF_NOTIFIER,
|
||||
CONF_PRESSURE_UNIT,
|
||||
CONF_SETTINGS,
|
||||
CONF_SKILL,
|
||||
CONF_USER_ID,
|
||||
DOMAIN,
|
||||
ISSUE_ID_DEPRECATED_PRESSURE_UNIT,
|
||||
ISSUE_ID_DEPRECATED_YAML_NOTIFIER,
|
||||
ISSUE_ID_DEPRECATED_YAML_SEVERAL_NOTIFIERS,
|
||||
ISSUE_ID_MISSING_SKILL_DATA,
|
||||
ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND,
|
||||
ConnectionType,
|
||||
EntityFilterSource,
|
||||
EntityId,
|
||||
)
|
||||
from .device import BacklightCapability, DeviceId, StateCapability
|
||||
from .helpers import APIError, CacheStore, SmartHomePlatform
|
||||
from .notifier import CloudNotifier, Notifier, NotifierConfig, YandexDirectNotifier
|
||||
from .property import StateProperty
|
||||
from .property_custom import CustomProperty, get_custom_property, get_event_platform_custom_property_type
|
||||
from .schema import CapabilityType, OnOffCapabilityInstance
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillConfig:
|
||||
"""Class to hold configuration of a smart home skill."""
|
||||
|
||||
user_id: str
|
||||
id: str
|
||||
token: str | None
|
||||
|
||||
|
||||
class ConfigEntryData:
|
||||
"""Class to hold config entry data."""
|
||||
|
||||
cache: CacheStore
|
||||
|
||||
_entity_registry: er.EntityRegistry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
yaml_config: ConfigType | None = None,
|
||||
entity_config: ConfigType | None = None,
|
||||
entity_filter: EntityFilter | None = None,
|
||||
):
|
||||
"""Initialize."""
|
||||
self.entry = entry
|
||||
self.entity_config: ConfigType = entity_config or {}
|
||||
self.unexposed_entities: set[str] = set()
|
||||
self._yaml_config: ConfigType = yaml_config or {}
|
||||
|
||||
self.component_version = "unknown"
|
||||
|
||||
self._hass = hass
|
||||
self._entity_filter = entity_filter
|
||||
self._cloud_manager: CloudManager | None = None
|
||||
self._notifiers: list[Notifier] = []
|
||||
|
||||
async def async_setup(self) -> Self:
|
||||
"""Set up the config entry data."""
|
||||
|
||||
self.cache = CacheStore(self._hass)
|
||||
await self.cache.async_load()
|
||||
|
||||
self._entity_registry = er.async_get(self._hass)
|
||||
|
||||
with suppress(KeyError):
|
||||
integration = (await async_get_custom_components(self._hass))[DOMAIN]
|
||||
self.component_version = str(integration.version)
|
||||
|
||||
if self.connection_type in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
|
||||
await self._async_setup_cloud_connection()
|
||||
|
||||
if self._hass.state == CoreState.running:
|
||||
await self._async_setup_notifiers()
|
||||
else:
|
||||
self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self._async_setup_notifiers)
|
||||
|
||||
if self._yaml_config.get(CONF_SETTINGS, {}).get(CONF_PRESSURE_UNIT):
|
||||
ir.async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
ISSUE_ID_DEPRECATED_PRESSURE_UNIT,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_ID_DEPRECATED_PRESSURE_UNIT,
|
||||
learn_more_url="https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/float/#unit-conversion",
|
||||
)
|
||||
else:
|
||||
ir.async_delete_issue(self._hass, DOMAIN, "deprecated_pressure_unit")
|
||||
|
||||
if count := len(self._yaml_config.get(CONF_NOTIFIER, [])):
|
||||
issue_id = ISSUE_ID_DEPRECATED_YAML_NOTIFIER if count == 1 else ISSUE_ID_DEPRECATED_YAML_SEVERAL_NOTIFIERS
|
||||
ir.async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=issue_id,
|
||||
learn_more_url="https://docs.yaha-cloud.ru/v1.0.x/breaking-changes/#v1-notifier",
|
||||
)
|
||||
else:
|
||||
ir.async_delete_issue(self._hass, DOMAIN, ISSUE_ID_DEPRECATED_YAML_NOTIFIER)
|
||||
ir.async_delete_issue(self._hass, DOMAIN, ISSUE_ID_DEPRECATED_YAML_SEVERAL_NOTIFIERS)
|
||||
|
||||
return self
|
||||
|
||||
async def async_unload(self) -> None:
|
||||
"""Unload the config entry data."""
|
||||
tasks = [asyncio.create_task(n.async_unload()) for n in self._notifiers]
|
||||
if self._cloud_manager:
|
||||
tasks.append(asyncio.create_task(self._cloud_manager.async_disconnect()))
|
||||
|
||||
if tasks:
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
return None
|
||||
|
||||
async def async_get_context_user_id(self) -> str | None:
|
||||
"""Return user id for service calls (cloud connection only)."""
|
||||
if user_id := self.entry.options.get(CONF_USER_ID):
|
||||
if user := await self._hass.auth.async_get_user(user_id):
|
||||
return user.id
|
||||
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def is_reporting_states(self) -> bool:
|
||||
"""Test if the config entry can report state changes."""
|
||||
if self.connection_type == ConnectionType.CLOUD:
|
||||
return True
|
||||
if self.platform == SmartHomePlatform.VK:
|
||||
return False
|
||||
|
||||
return self.skill is not None
|
||||
|
||||
@property
|
||||
def use_cloud_stream(self) -> bool:
|
||||
"""Test if the config entry use video streaming through the cloud."""
|
||||
if self.connection_type in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
|
||||
return True
|
||||
|
||||
settings = self._yaml_config.get(CONF_SETTINGS, {})
|
||||
return bool(settings.get(CONF_CLOUD_STREAM))
|
||||
|
||||
@property
|
||||
def use_entry_aliases(self) -> bool:
|
||||
"""Test if device or area entry aliases should be used for device or room name."""
|
||||
return bool(self.entry.options.get(CONF_ENTRY_ALIASES, True))
|
||||
|
||||
@property
|
||||
def connection_type(self) -> ConnectionType:
|
||||
"""Return connection type."""
|
||||
return ConnectionType(str(self.entry.data.get(CONF_CONNECTION_TYPE)))
|
||||
|
||||
@property
|
||||
def cloud_instance_id(self) -> str:
|
||||
"""Return cloud instance id."""
|
||||
if self.connection_type in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
|
||||
return str(self.entry.data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID])
|
||||
|
||||
raise ValueError("Config entry uses direct connection")
|
||||
|
||||
@property
|
||||
def cloud_connection_token(self) -> str:
|
||||
"""Return cloud connection token."""
|
||||
if self.connection_type in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS):
|
||||
return str(self.entry.data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN])
|
||||
|
||||
raise ValueError("Config entry uses direct connection")
|
||||
|
||||
@property
|
||||
def platform(self) -> SmartHomePlatform | None:
|
||||
"""Return smart home platform."""
|
||||
if self.connection_type == ConnectionType.CLOUD:
|
||||
return None
|
||||
|
||||
return SmartHomePlatform(self.entry.data[CONF_PLATFORM])
|
||||
|
||||
@cached_property
|
||||
def skill(self) -> SkillConfig | None:
|
||||
"""Return configuration for the skill."""
|
||||
config = self.entry.options.get(CONF_SKILL)
|
||||
if not config:
|
||||
return None
|
||||
|
||||
user_id = self.cloud_instance_id if self.connection_type == ConnectionType.CLOUD_PLUS else config[CONF_USER_ID]
|
||||
return SkillConfig(user_id=user_id, id=config[CONF_ID], token=config.get(CONF_TOKEN))
|
||||
|
||||
@property
|
||||
def color_profiles(self) -> ColorProfiles:
|
||||
"""Return color profiles."""
|
||||
return ColorProfiles.from_dict(self._yaml_config.get(CONF_COLOR_PROFILE, {}))
|
||||
|
||||
def get_entity_config(self, entity_id: str) -> ConfigType:
|
||||
"""Return configuration for the entity."""
|
||||
return cast(ConfigType, self.entity_config.get(entity_id, {}))
|
||||
|
||||
def should_expose(self, entity_id: str) -> bool:
|
||||
"""Test if the entity should be exposed."""
|
||||
if self.entry.options.get(CONF_FILTER_SOURCE) == EntityFilterSource.LABEL:
|
||||
entity_entry = self._entity_registry.async_get(entity_id)
|
||||
if not entity_entry:
|
||||
return False
|
||||
|
||||
return self.entry.options[CONF_LABEL] in entity_entry.labels
|
||||
|
||||
if self._entity_filter and not self._entity_filter.empty_filter:
|
||||
return self._entity_filter(entity_id)
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def linked_platforms(self) -> set[SmartHomePlatform]:
|
||||
"""Return list of smart home platforms linked with the config entry."""
|
||||
platforms: set[SmartHomePlatform] = set()
|
||||
for platform in self.entry.data.get(CONF_LINKED_PLATFORMS, []):
|
||||
try:
|
||||
platforms.add(SmartHomePlatform(platform))
|
||||
except ValueError:
|
||||
_LOGGER.error(f"Unsupported platform: {platform}")
|
||||
|
||||
return platforms
|
||||
|
||||
def link_platform(self, platform: SmartHomePlatform) -> None:
|
||||
"""Link smart home platform to this config entry (device discovery)."""
|
||||
if platform in self.linked_platforms:
|
||||
return
|
||||
|
||||
data = self.entry.data.copy()
|
||||
data[CONF_LINKED_PLATFORMS] = data.get(CONF_LINKED_PLATFORMS, []) + [platform]
|
||||
|
||||
self._hass.config_entries.async_update_entry(self.entry, data=data)
|
||||
|
||||
def unlink_platform(self, platform: SmartHomePlatform) -> None:
|
||||
"""Unlink smart home platform."""
|
||||
data = self.entry.data.copy()
|
||||
data[CONF_LINKED_PLATFORMS] = list(self.linked_platforms - {platform})
|
||||
|
||||
self._hass.config_entries.async_update_entry(self.entry, data=data)
|
||||
|
||||
def mark_entity_unexposed(self, entity_id: str) -> None:
|
||||
"""Create an issue for unexposed entity."""
|
||||
_LOGGER.warning(
|
||||
f"Device for {entity_id} exists in Yandex, but entity {entity_id} not exposed via integration settings. "
|
||||
f"Please either expose the entity or delete the device from Yandex."
|
||||
)
|
||||
|
||||
self.unexposed_entities.add(entity_id)
|
||||
issue_id = ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + self.entry.options[CONF_FILTER_SOURCE]
|
||||
|
||||
formatted_entities: list[str] = []
|
||||
for entity_id in sorted(self.unexposed_entities):
|
||||
if self.entry.options[CONF_FILTER_SOURCE] == EntityFilterSource.YAML:
|
||||
formatted_entities.append(f"* `- {entity_id}`")
|
||||
else:
|
||||
state = self._hass.states.get(entity_id) or State(entity_id, STATE_UNKNOWN)
|
||||
formatted_entities.append(f"* `{state.entity_id}` ({state.name})")
|
||||
|
||||
ir.async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=self.entry.options[CONF_FILTER_SOURCE] != EntityFilterSource.YAML,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
data={"entry_id": self.entry.entry_id},
|
||||
translation_key=issue_id,
|
||||
translation_placeholders={
|
||||
"entry_title": self.entry.title,
|
||||
"entities": "\n".join(formatted_entities),
|
||||
},
|
||||
learn_more_url="https://docs.yaha-cloud.ru/v1.0.x/config/filter/",
|
||||
)
|
||||
|
||||
async def _async_setup_notifiers(self, *_: Any) -> None:
|
||||
"""Set up notifiers."""
|
||||
if self.is_reporting_states or self.platform == SmartHomePlatform.VK:
|
||||
ir.async_delete_issue(self._hass, DOMAIN, ISSUE_ID_MISSING_SKILL_DATA)
|
||||
else:
|
||||
ir.async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
ISSUE_ID_MISSING_SKILL_DATA,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_ID_MISSING_SKILL_DATA,
|
||||
translation_placeholders={"entry_title": self.entry.title},
|
||||
)
|
||||
return
|
||||
|
||||
if not self.linked_platforms:
|
||||
return
|
||||
|
||||
track_templates = self._get_trackable_templates()
|
||||
track_entity_states = self._get_trackable_entity_states()
|
||||
extended_log = len(self._hass.config_entries.async_entries(DOMAIN)) > 1
|
||||
|
||||
match self.connection_type:
|
||||
case ConnectionType.CLOUD:
|
||||
for platform in self.linked_platforms:
|
||||
config = NotifierConfig(
|
||||
user_id=self.cloud_instance_id,
|
||||
token=self.cloud_connection_token,
|
||||
platform=platform,
|
||||
extended_log=extended_log,
|
||||
)
|
||||
self._notifiers.append(
|
||||
CloudNotifier(self._hass, self, config, track_templates, track_entity_states)
|
||||
)
|
||||
|
||||
case ConnectionType.CLOUD_PLUS:
|
||||
if self.platform == SmartHomePlatform.YANDEX and self.skill and self.skill.token:
|
||||
config = NotifierConfig(
|
||||
user_id=self.cloud_instance_id,
|
||||
token=self.skill.token,
|
||||
skill_id=self.skill.id,
|
||||
extended_log=extended_log,
|
||||
)
|
||||
self._notifiers.append(
|
||||
YandexDirectNotifier(self._hass, self, config, track_templates, track_entity_states)
|
||||
)
|
||||
|
||||
case ConnectionType.DIRECT:
|
||||
if self.platform == SmartHomePlatform.YANDEX and self.skill and self.skill.token:
|
||||
config = NotifierConfig(
|
||||
user_id=self.skill.user_id,
|
||||
token=self.skill.token,
|
||||
skill_id=self.skill.id,
|
||||
extended_log=extended_log,
|
||||
)
|
||||
self._notifiers.append(
|
||||
YandexDirectNotifier(self._hass, self, config, track_templates, track_entity_states)
|
||||
)
|
||||
|
||||
if self._notifiers:
|
||||
await asyncio.wait([asyncio.create_task(n.async_setup()) for n in self._notifiers])
|
||||
|
||||
return None
|
||||
|
||||
async def _async_setup_cloud_connection(self) -> None:
|
||||
"""Set up the cloud connection."""
|
||||
self._cloud_manager = CloudManager(self._hass, self)
|
||||
|
||||
self._hass.loop.create_task(self._cloud_manager.async_connect())
|
||||
return self.entry.async_on_unload(
|
||||
self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._cloud_manager.async_disconnect)
|
||||
)
|
||||
|
||||
def _append_trackable_templates_with_capability(
|
||||
self,
|
||||
templates: dict[Template, list[CustomCapability | CustomProperty]],
|
||||
capability_config: ConfigType,
|
||||
capability_type: CapabilityType,
|
||||
instance: str,
|
||||
device_id: str,
|
||||
) -> None:
|
||||
"""Append custom capability to list of templates."""
|
||||
try:
|
||||
capability = get_custom_capability(
|
||||
self._hass,
|
||||
self,
|
||||
capability_config,
|
||||
capability_type,
|
||||
instance,
|
||||
device_id,
|
||||
)
|
||||
except APIError as e:
|
||||
_LOGGER.debug(f"Failed to track custom capability: {e}")
|
||||
return
|
||||
|
||||
template = capability_custom.get_value_template(self._hass, device_id, capability_config)
|
||||
|
||||
if template:
|
||||
templates.setdefault(template, [])
|
||||
templates[template].append(capability)
|
||||
|
||||
def _get_trackable_templates(self) -> dict[Template, list[CustomCapability | CustomProperty]]:
|
||||
"""Return templates for track changes."""
|
||||
templates: dict[Template, list[CustomCapability | CustomProperty]] = {}
|
||||
|
||||
for device_id, entity_config in self.entity_config.items():
|
||||
if not self.should_expose(device_id):
|
||||
continue
|
||||
|
||||
if (state_template := entity_config.get(CONF_STATE_TEMPLATE)) is not None:
|
||||
self._append_trackable_templates_with_capability(
|
||||
templates,
|
||||
{CONF_STATE_TEMPLATE: state_template},
|
||||
CapabilityType.ON_OFF,
|
||||
OnOffCapabilityInstance.ON,
|
||||
device_id,
|
||||
)
|
||||
|
||||
for capability_type, config_key in (
|
||||
(CapabilityType.MODE, CONF_ENTITY_CUSTOM_MODES),
|
||||
(CapabilityType.TOGGLE, CONF_ENTITY_CUSTOM_TOGGLES),
|
||||
(CapabilityType.RANGE, CONF_ENTITY_CUSTOM_RANGES),
|
||||
):
|
||||
if config_key in entity_config:
|
||||
for instance in entity_config[config_key]:
|
||||
capability_config = entity_config[config_key][instance]
|
||||
if isinstance(capability_config, dict):
|
||||
self._append_trackable_templates_with_capability(
|
||||
templates, capability_config, capability_type, instance, device_id
|
||||
)
|
||||
|
||||
for property_config in entity_config.get(CONF_ENTITY_PROPERTIES, []):
|
||||
try:
|
||||
if not (custom_property := get_custom_property(self._hass, self, property_config, device_id)):
|
||||
continue
|
||||
template = property_custom.get_value_template(self._hass, device_id, property_config)
|
||||
templates.setdefault(template, [])
|
||||
templates[template].append(custom_property)
|
||||
except APIError as e:
|
||||
_LOGGER.debug(f"Failed to track custom property: {e}")
|
||||
|
||||
return templates
|
||||
|
||||
def _get_trackable_entity_states(
|
||||
self,
|
||||
) -> dict[EntityId, list[tuple[DeviceId, type[StateProperty | StateCapability[Any]]]]]:
|
||||
"""Return entity capability and property class types to track state changes."""
|
||||
states: dict[EntityId, list[tuple[DeviceId, type[StateProperty | StateCapability[Any]]]]] = {}
|
||||
|
||||
def _states_append(_entity_id: str, _device_id: str, t: type[StateProperty | StateCapability[Any]]) -> None:
|
||||
states.setdefault(_entity_id, [])
|
||||
states[_entity_id].append((_device_id, t))
|
||||
|
||||
for device_id, entity_config in self.entity_config.items():
|
||||
if not self.should_expose(device_id):
|
||||
continue
|
||||
|
||||
for property_config in entity_config.get(CONF_ENTITY_PROPERTIES, []):
|
||||
if event_platform_property := get_event_platform_custom_property_type(property_config):
|
||||
entity_id: str = property_config[CONF_ENTITY_PROPERTY_ENTITY]
|
||||
_states_append(entity_id, device_id, event_platform_property)
|
||||
|
||||
if backlight_entity_id := entity_config.get(CONF_BACKLIGHT_ENTITY_ID):
|
||||
_states_append(backlight_entity_id, device_id, BacklightCapability)
|
||||
|
||||
return states
|
||||
@@ -0,0 +1,170 @@
|
||||
"""The Yandex Smart Home request handlers."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
from .const import ATTR_CAPABILITY, ATTR_ERROR_CODE, EVENT_DEVICE_ACTION
|
||||
from .device import Device, async_get_device_description, async_get_device_states, async_get_devices
|
||||
from .helpers import ActionNotAllowed, APIError, RequestData
|
||||
from .schema import (
|
||||
ActionRequest,
|
||||
ActionResult,
|
||||
ActionResultCapability,
|
||||
ActionResultCapabilityState,
|
||||
ActionResultDevice,
|
||||
DeviceDescription,
|
||||
DeviceList,
|
||||
DeviceStates,
|
||||
FailedActionResult,
|
||||
Response,
|
||||
ResponseCode,
|
||||
ResponsePayload,
|
||||
StatesRequest,
|
||||
SuccessActionResult,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
HANDLERS: Registry[
|
||||
str,
|
||||
Callable[
|
||||
[HomeAssistant, RequestData, str],
|
||||
Coroutine[Any, Any, ResponsePayload | None],
|
||||
],
|
||||
] = Registry()
|
||||
|
||||
|
||||
async def async_handle_request(hass: HomeAssistant, data: RequestData, action: str, payload: str) -> Response:
|
||||
"""Handle incoming API request."""
|
||||
handler = HANDLERS.get(action)
|
||||
|
||||
if handler is None:
|
||||
_LOGGER.error(f"Unexpected action '{action}'")
|
||||
return Response(request_id=data.request_id)
|
||||
|
||||
try:
|
||||
return Response(request_id=data.request_id, payload=await handler(hass, data, payload))
|
||||
except APIError as err:
|
||||
_LOGGER.error(f"{err.message} ({err.code})")
|
||||
return Response(request_id=data.request_id)
|
||||
except Exception:
|
||||
# return always 200 due to blocking error on device page
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return Response(request_id=data.request_id)
|
||||
|
||||
|
||||
@HANDLERS.register("/user/devices")
|
||||
async def async_device_list(hass: HomeAssistant, data: RequestData, _payload: str) -> DeviceList:
|
||||
"""Handle request that return information about supported user devices.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/reference/get-devices.html
|
||||
"""
|
||||
assert data.request_user_id
|
||||
|
||||
devices: list[DeviceDescription] = []
|
||||
for device in await async_get_devices(hass, data.entry_data):
|
||||
if (description := await async_get_device_description(hass, device)) is not None:
|
||||
devices.append(description)
|
||||
|
||||
data.entry_data.link_platform(data.platform)
|
||||
return DeviceList(user_id=data.request_user_id, devices=devices)
|
||||
|
||||
|
||||
@HANDLERS.register("/user/devices/query")
|
||||
async def async_devices_query(hass: HomeAssistant, data: RequestData, payload: str) -> DeviceStates:
|
||||
"""Handle request that return information about the states of user devices.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/reference/post-devices-query.html
|
||||
"""
|
||||
request = StatesRequest.parse_raw(payload)
|
||||
states = await async_get_device_states(hass, data.entry_data, [rd.id for rd in request.devices])
|
||||
return DeviceStates(devices=states)
|
||||
|
||||
|
||||
@HANDLERS.register("/user/devices/action")
|
||||
async def async_devices_action(hass: HomeAssistant, data: RequestData, payload: str) -> ActionResult:
|
||||
"""Handle request that changes current state of user devices.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/reference/post-action.html
|
||||
"""
|
||||
request = ActionRequest.parse_raw(payload)
|
||||
results: list[ActionResultDevice] = []
|
||||
|
||||
for device_id, actions in [(rd.id, rd.capabilities) for rd in request.payload.devices]:
|
||||
state = hass.states.get(device_id)
|
||||
device = Device(hass, data.entry_data, device_id, state)
|
||||
|
||||
if state and not device.should_expose:
|
||||
data.entry_data.mark_entity_unexposed(state.entity_id)
|
||||
|
||||
if device.unavailable:
|
||||
hass.bus.async_fire(
|
||||
EVENT_DEVICE_ACTION,
|
||||
{ATTR_ENTITY_ID: device_id, ATTR_ERROR_CODE: ResponseCode.DEVICE_UNREACHABLE.value},
|
||||
context=data.context,
|
||||
)
|
||||
|
||||
results.append(
|
||||
ActionResultDevice(
|
||||
id=device_id, action_result=FailedActionResult(error_code=ResponseCode.DEVICE_UNREACHABLE)
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
capability_results: list[ActionResultCapability] = []
|
||||
for action in actions:
|
||||
try:
|
||||
value = await device.execute(data.context, action)
|
||||
hass.bus.async_fire(
|
||||
EVENT_DEVICE_ACTION,
|
||||
{ATTR_ENTITY_ID: device_id, ATTR_CAPABILITY: action.as_dict()},
|
||||
context=data.context,
|
||||
)
|
||||
except (APIError, ActionNotAllowed) as err:
|
||||
if isinstance(err, APIError):
|
||||
_LOGGER.error(f"{err.message} ({err.code.value})")
|
||||
|
||||
hass.bus.async_fire(
|
||||
EVENT_DEVICE_ACTION,
|
||||
{ATTR_ENTITY_ID: device_id, ATTR_CAPABILITY: action.as_dict(), ATTR_ERROR_CODE: err.code.value},
|
||||
context=data.context,
|
||||
)
|
||||
|
||||
capability_results.append(
|
||||
ActionResultCapability(
|
||||
type=action.type,
|
||||
state=ActionResultCapabilityState(
|
||||
instance=action.state.instance,
|
||||
action_result=FailedActionResult(error_code=ResponseCode(err.code)),
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
capability_results.append(
|
||||
ActionResultCapability(
|
||||
type=action.type,
|
||||
state=ActionResultCapabilityState(
|
||||
instance=action.state.instance,
|
||||
value=value,
|
||||
action_result=SuccessActionResult(),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
results.append(ActionResultDevice(id=device_id, capabilities=capability_results))
|
||||
|
||||
return ActionResult(devices=results)
|
||||
|
||||
|
||||
@HANDLERS.register("/user/unlink")
|
||||
async def async_user_unlink(_hass: HomeAssistant, data: RequestData, _payload: str) -> None:
|
||||
"""Handle request indicates that the user has unlink the account.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/reference/unlink.html
|
||||
"""
|
||||
data.entry_data.unlink_platform(data.platform)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Helper classes for Yandex Smart Home integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import area_registry as ar, device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
from .schema import ResponseCode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entry_data import ConfigEntryData
|
||||
|
||||
STORE_CACHE_ATTRS = "attrs"
|
||||
|
||||
|
||||
@callback
|
||||
def _get_registry_entries(hass: HomeAssistant, entity_id: str) -> tuple[
|
||||
er.RegistryEntry | None,
|
||||
dr.DeviceEntry | None,
|
||||
ar.AreaEntry | None,
|
||||
]:
|
||||
"""Get registry entries."""
|
||||
ent_reg = er.async_get(hass)
|
||||
dev_reg = dr.async_get(hass)
|
||||
area_reg = ar.async_get(hass)
|
||||
|
||||
if (entity_entry := ent_reg.async_get(entity_id)) and entity_entry.device_id:
|
||||
device_entry = dev_reg.devices.get(entity_entry.device_id)
|
||||
else:
|
||||
device_entry = None
|
||||
|
||||
if entity_entry and entity_entry.area_id:
|
||||
area_id = entity_entry.area_id
|
||||
elif device_entry and device_entry.area_id:
|
||||
area_id = device_entry.area_id
|
||||
else:
|
||||
area_id = None
|
||||
|
||||
if area_id is not None:
|
||||
area_entry = area_reg.async_get_area(area_id)
|
||||
else:
|
||||
area_entry = None
|
||||
|
||||
return entity_entry, device_entry, area_entry
|
||||
|
||||
|
||||
class APIError(HomeAssistantError):
|
||||
"""Base API error."""
|
||||
|
||||
def __init__(self, code: ResponseCode, message: str):
|
||||
"""Init the error."""
|
||||
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
class ActionNotAllowed(HomeAssistantError):
|
||||
"""Error producted when change capability state is not allowed, no logging."""
|
||||
|
||||
def __init__(self, code: ResponseCode = ResponseCode.REMOTE_CONTROL_DISABLED):
|
||||
"""Init the error."""
|
||||
|
||||
self.code = code
|
||||
|
||||
|
||||
class CacheStore:
|
||||
"""Cache store for Yandex Smart Home."""
|
||||
|
||||
_STORAGE_VERSION = 1
|
||||
_STORAGE_KEY = f"{DOMAIN}.cache"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize a cache store."""
|
||||
self._hass = hass
|
||||
self._store = Store[dict[str, Any]](hass, self._STORAGE_VERSION, self._STORAGE_KEY)
|
||||
self._data: dict[str, dict[str, Any]] = {STORE_CACHE_ATTRS: {}}
|
||||
|
||||
def get_attr_value(self, entity_id: str, attr: str) -> Any | None:
|
||||
"""Return a cached value of attribute for entity."""
|
||||
if entity_id not in self._data[STORE_CACHE_ATTRS]:
|
||||
return None
|
||||
|
||||
return self._data[STORE_CACHE_ATTRS][entity_id].get(attr)
|
||||
|
||||
@callback
|
||||
def save_attr_value(self, entity_id: str, attr: str, value: Any) -> None:
|
||||
"""Cache entity's attribute value to disk."""
|
||||
if entity_id not in self._data[STORE_CACHE_ATTRS]:
|
||||
self._data[STORE_CACHE_ATTRS][entity_id] = {}
|
||||
has_changed = True
|
||||
else:
|
||||
has_changed = self._data[STORE_CACHE_ATTRS][entity_id][attr] != value
|
||||
|
||||
self._data[STORE_CACHE_ATTRS][entity_id][attr] = value
|
||||
|
||||
if has_changed:
|
||||
self._store.async_delay_save(lambda: self._data, 5.0)
|
||||
|
||||
return None
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load store data."""
|
||||
data = await self._store.async_load()
|
||||
if data:
|
||||
self._data = data
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class SmartHomePlatform(StrEnum):
|
||||
"""Supported smart home platform."""
|
||||
|
||||
YANDEX = "yandex"
|
||||
VK = "vk"
|
||||
|
||||
@classmethod
|
||||
def from_client_id(cls, client_id: str) -> SmartHomePlatform | None:
|
||||
"""Return platform for OAuth2 client id."""
|
||||
host = urlparse(client_id).netloc
|
||||
if "yandex" in host:
|
||||
return cls.YANDEX
|
||||
elif host == "vc.go.mail.ru":
|
||||
return cls.VK
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestData:
|
||||
"""Hold data associated with a particular request."""
|
||||
|
||||
entry_data: ConfigEntryData
|
||||
context: Context
|
||||
platform: SmartHomePlatform
|
||||
request_user_id: str | None
|
||||
request_id: str | None
|
||||
|
||||
|
||||
class HasInstance(Protocol):
|
||||
"""Protocol type for objects that has instance attribute."""
|
||||
|
||||
instance: Any
|
||||
|
||||
|
||||
_HasInstanceT = TypeVar("_HasInstanceT", bound=type[HasInstance])
|
||||
|
||||
|
||||
class DictRegistry(dict[str, _HasInstanceT]):
|
||||
"""Dict Registry for types with instance attribute."""
|
||||
|
||||
def register(self, obj: _HasInstanceT) -> _HasInstanceT:
|
||||
"""Register decorated type."""
|
||||
self[obj.instance] = obj
|
||||
return obj
|
||||
|
||||
|
||||
class ListRegistry[_T](list[_T]):
|
||||
"""List Registry of items."""
|
||||
|
||||
def register(self, obj: _T) -> _T:
|
||||
"""Register decorated type."""
|
||||
self.append(obj)
|
||||
return obj
|
||||
161
homeassistant/config/custom_components/yandex_smart_home/http.py
Normal file
161
homeassistant/config/custom_components/yandex_smart_home/http.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""The Yandex Smart Home HTTP interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, TypeVar
|
||||
|
||||
from aiohttp.web import HTTPServiceUnavailable, Request, Response, json_response
|
||||
from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID, HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from . import DOMAIN, handlers
|
||||
from .const import ISSUE_ID_MISSING_INTEGRATION
|
||||
from .helpers import RequestData, SmartHomePlatform
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import YandexSmartHome
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_T = TypeVar("_T", bound="YandexSmartHomeView")
|
||||
|
||||
|
||||
async def _log_request(request: Request) -> None:
|
||||
"""Log the request."""
|
||||
if body := await request.text():
|
||||
_LOGGER.debug(f"Request: {request.url} ({request.method} data: {body})")
|
||||
else:
|
||||
_LOGGER.debug(f"Request: {request.url} ({request.method})")
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_http(hass: HomeAssistant, component: YandexSmartHome) -> None:
|
||||
"""Register HTTP views for Yandex Smart Home."""
|
||||
hass.http.register_view(YandexSmartHomeUnauthorizedView(component))
|
||||
return hass.http.register_view(YandexSmartHomeAPIView(component))
|
||||
|
||||
|
||||
def async_http_request(
|
||||
func: Callable[[_T, HomeAssistant, Request, RequestData], Awaitable[Response]]
|
||||
) -> Callable[[_T, Request], Coroutine[Any, Any, Response]]:
|
||||
"""Decorate an async function to handle authorized HTTP requests."""
|
||||
|
||||
async def decorator(self: _T, request: Request) -> Response:
|
||||
"""Decorate."""
|
||||
await _log_request(request)
|
||||
|
||||
hass: HomeAssistant = request.app[KEY_HASS]
|
||||
context = self.context(request)
|
||||
|
||||
refresh_token = hass.auth.async_get_refresh_token(request[KEY_HASS_REFRESH_TOKEN_ID])
|
||||
assert refresh_token is not None
|
||||
|
||||
platform = SmartHomePlatform.from_client_id(refresh_token.client_id or "")
|
||||
if not platform:
|
||||
_LOGGER.error(f"Request from unsupported platform, client_id: {refresh_token.client_id}")
|
||||
raise HTTPServiceUnavailable()
|
||||
|
||||
entry_data = self._component.get_direct_connection_entry_data(platform, refresh_token.user.id)
|
||||
if not entry_data and len(hass.config_entries.async_entries(DOMAIN)) == 1:
|
||||
# backward compatibility
|
||||
entry_data = self._component.get_direct_connection_entry_data(platform, None)
|
||||
|
||||
issue_id = f"{ISSUE_ID_MISSING_INTEGRATION}_{platform}_{refresh_token.user.id}"
|
||||
if not entry_data:
|
||||
_LOGGER.error(
|
||||
f"Failed to find Yandex Smart Home integration for request "
|
||||
f"from {platform} (user {refresh_token.user.name})"
|
||||
)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key=ISSUE_ID_MISSING_INTEGRATION,
|
||||
translation_placeholders={
|
||||
"platform": platform,
|
||||
"username": refresh_token.user.name or refresh_token.user.id,
|
||||
},
|
||||
)
|
||||
raise HTTPServiceUnavailable()
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
data = RequestData(
|
||||
entry_data=entry_data,
|
||||
context=context,
|
||||
platform=platform,
|
||||
request_user_id=context.user_id,
|
||||
request_id=request.headers.get("X-Request-Id"),
|
||||
)
|
||||
if entry_data.skill:
|
||||
data.request_user_id = entry_data.skill.user_id
|
||||
|
||||
return await func(self, hass, request, data)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class YandexSmartHomeView(HomeAssistantView):
|
||||
def __init__(self, component: YandexSmartHome):
|
||||
self._component = component
|
||||
|
||||
|
||||
class YandexSmartHomeUnauthorizedView(YandexSmartHomeView):
|
||||
"""View to handle Yandex Smart Home unauthorized HTTP requests."""
|
||||
|
||||
url = f"/api/{DOMAIN}/v1.0"
|
||||
extra_urls = [
|
||||
url + "/ping",
|
||||
]
|
||||
name = f"api:{DOMAIN}:unauthorized"
|
||||
requires_auth = False
|
||||
|
||||
@staticmethod
|
||||
async def head(request: Request) -> Response:
|
||||
"""Handle Yandex Smart Home HEAD requests."""
|
||||
await _log_request(request)
|
||||
return Response(status=200)
|
||||
|
||||
@staticmethod
|
||||
async def get(request: Request) -> Response:
|
||||
"""Handle Yandex Smart Home GET requests."""
|
||||
await _log_request(request)
|
||||
return Response(text="Yandex Smart Home", status=200)
|
||||
|
||||
|
||||
class YandexSmartHomeAPIView(YandexSmartHomeView):
|
||||
"""View to handle Yandex Smart Home HTTP requests."""
|
||||
|
||||
url = f"/api/{DOMAIN}/v1.0"
|
||||
extra_urls = [
|
||||
url + "/user/unlink",
|
||||
url + "/user/devices",
|
||||
url + "/user/devices/query",
|
||||
url + "/user/devices/action",
|
||||
]
|
||||
name = f"api:{DOMAIN}"
|
||||
requires_auth = True
|
||||
|
||||
async def _async_handle_request(self, hass: HomeAssistant, request: Request, data: RequestData) -> Response:
|
||||
"""Handle Yandex Smart Home requests."""
|
||||
assert self.url is not None
|
||||
result = await handlers.async_handle_request(
|
||||
hass, data, action=request.path.replace(self.url, "", 1), payload=await request.text()
|
||||
)
|
||||
response = json_response(text=result.as_json())
|
||||
_LOGGER.debug(f"Response: {response.text}")
|
||||
|
||||
return response
|
||||
|
||||
@async_http_request
|
||||
async def post(self, hass: HomeAssistant, request: Request, data: RequestData) -> Response:
|
||||
"""Handle Yandex Smart Home POST requests."""
|
||||
return await self._async_handle_request(hass, request, data)
|
||||
|
||||
@async_http_request
|
||||
async def get(self, hass: HomeAssistant, request: Request, data: RequestData) -> Response:
|
||||
"""Handle Yandex Smart Home GET requests."""
|
||||
return await self._async_handle_request(hass, request, data)
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"domain": "yandex_smart_home",
|
||||
"name": "Yandex Smart Home",
|
||||
"config_flow": true,
|
||||
"documentation": "https://docs.yaha-cloud.ru/v1.0.x/",
|
||||
"issue_tracker": "https://github.com/dext0r/yandex_smart_home/issues",
|
||||
"requirements": [],
|
||||
"after_dependencies": ["local_calendar"],
|
||||
"iot_class": "cloud_polling",
|
||||
"type": "service",
|
||||
"dependencies": ["http"],
|
||||
"codeowners": ["@dext0r"],
|
||||
"version": "1.0.2"
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
"""Implement the Yandex Smart Home event notification service (notifier)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import itertools
|
||||
import logging
|
||||
from random import randint
|
||||
from typing import TYPE_CHECKING, Any, Mapping, Protocol, Self, Sequence
|
||||
|
||||
from aiohttp import ClientTimeout, JsonPayload, hdrs
|
||||
from aiohttp.client_exceptions import ClientConnectionError
|
||||
from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED
|
||||
from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, State
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE, async_create_clientsession
|
||||
from homeassistant.helpers.event import (
|
||||
EventStateChangedData,
|
||||
TrackTemplate,
|
||||
TrackTemplateResult,
|
||||
TrackTemplateResultInfo,
|
||||
async_call_later,
|
||||
async_track_template_result,
|
||||
)
|
||||
from homeassistant.helpers.template import Template
|
||||
from pydantic.v1 import ValidationError
|
||||
|
||||
from . import DOMAIN
|
||||
from .capability import Capability
|
||||
from .const import CLOUD_BASE_URL, EntityId
|
||||
from .device import Device, DeviceId
|
||||
from .helpers import APIError, SmartHomePlatform
|
||||
from .property import Property
|
||||
from .schema import (
|
||||
CallbackDiscoveryRequest,
|
||||
CallbackDiscoveryRequestPayload,
|
||||
CallbackRequest,
|
||||
CallbackResponse,
|
||||
CallbackStatesRequest,
|
||||
CallbackStatesRequestPayload,
|
||||
CapabilityInstanceState,
|
||||
DeviceState,
|
||||
PropertyInstanceState,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entry_data import ConfigEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
INITIAL_REPORT_DELAY = timedelta(seconds=15)
|
||||
DISCOVERY_REQUEST_DELAY = timedelta(seconds=5)
|
||||
HEARTBEAT_REPORT_INTERVAL = timedelta(hours=1)
|
||||
REPORT_STATE_WINDOW = timedelta(seconds=1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotifierConfig:
|
||||
"""Hold configuration for a notifier."""
|
||||
|
||||
user_id: str
|
||||
token: str
|
||||
skill_id: str | None = None
|
||||
platform: SmartHomePlatform | None = None
|
||||
extended_log: bool = False
|
||||
|
||||
|
||||
class ReportableDeviceState(Protocol):
|
||||
"""Protocol type for device capabilities and properties."""
|
||||
|
||||
device_id: str
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def time_sensitive(self) -> bool:
|
||||
"""Test if value changes should be reported immediately."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def check_value_change(self, other: Self | None) -> bool:
|
||||
"""Test if the state value differs from other state."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_value(self) -> Any:
|
||||
"""Return the current state value."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_instance_state(self) -> CapabilityInstanceState | PropertyInstanceState | None:
|
||||
"""Return a state for a state query request."""
|
||||
...
|
||||
|
||||
|
||||
class ReportableDeviceStateFromEntityState(ReportableDeviceState, Protocol):
|
||||
@abstractmethod
|
||||
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData, device_id: str, state: State):
|
||||
"""Initialize a capability or property for the state."""
|
||||
...
|
||||
|
||||
|
||||
class ReportableTemplateDeviceState(ReportableDeviceState, Protocol):
|
||||
"""Protocol type for custom properties and capabilities."""
|
||||
|
||||
@abstractmethod
|
||||
def new_with_value(self, value: Any) -> Self:
|
||||
"""Return copy of the state with new value."""
|
||||
...
|
||||
|
||||
|
||||
class PendingStates:
|
||||
"""Hold states that about to be reported."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize."""
|
||||
self._device_states: dict[str, list[ReportableDeviceState]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def async_add(
|
||||
self,
|
||||
new_states: Sequence[ReportableDeviceState],
|
||||
old_states: Sequence[ReportableDeviceState],
|
||||
) -> list[ReportableDeviceState]:
|
||||
"""Add changed states to pending and return list of them."""
|
||||
scheduled_states: list[ReportableDeviceState] = []
|
||||
|
||||
async with self._lock:
|
||||
for state in new_states:
|
||||
try:
|
||||
old_state = old_states[old_states.index(state)]
|
||||
except ValueError:
|
||||
old_state = None
|
||||
try:
|
||||
if state.check_value_change(old_state):
|
||||
device_states = self._device_states.setdefault(state.device_id, [])
|
||||
with suppress(ValueError):
|
||||
device_states.remove(state)
|
||||
|
||||
device_states.append(state)
|
||||
scheduled_states.append(state)
|
||||
except APIError as e:
|
||||
_LOGGER.warning(e)
|
||||
|
||||
return scheduled_states
|
||||
|
||||
async def async_get_all(self) -> dict[str, list[ReportableDeviceState]]:
|
||||
"""Return all states and clear pending."""
|
||||
async with self._lock:
|
||||
states = self._device_states.copy()
|
||||
self._device_states.clear()
|
||||
return states
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
"""Test if pending states exist."""
|
||||
return not bool(self._device_states)
|
||||
|
||||
@property
|
||||
def time_sensitive(self) -> bool:
|
||||
"""Test if pending states should be sent immediately."""
|
||||
for state in itertools.chain(*self._device_states.values()):
|
||||
if state.time_sensitive:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class Notifier(ABC):
|
||||
"""Base class for a notifier."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_data: ConfigEntryData,
|
||||
config: NotifierConfig,
|
||||
track_templates: Mapping[Template, Sequence[ReportableTemplateDeviceState]],
|
||||
track_entity_states: Mapping[EntityId, Sequence[tuple[DeviceId, type[ReportableDeviceStateFromEntityState]]]],
|
||||
):
|
||||
"""Initialize."""
|
||||
self._hass = hass
|
||||
self._entry_data = entry_data
|
||||
self._config = config
|
||||
self._session = async_create_clientsession(hass)
|
||||
|
||||
self._pending = PendingStates()
|
||||
|
||||
self._track_entity_states = track_entity_states
|
||||
self._track_templates = track_templates
|
||||
self._template_changes_tracker: TrackTemplateResultInfo | None = None
|
||||
|
||||
self._unsub_state_changed: CALLBACK_TYPE | None = None
|
||||
self._unsub_initial_report: CALLBACK_TYPE | None = None
|
||||
self._unsub_heartbeat_report: CALLBACK_TYPE | None = None
|
||||
self._unsub_report_states: CALLBACK_TYPE | None = None
|
||||
self._unsub_discovery: CALLBACK_TYPE | None = None
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the notifier."""
|
||||
self._unsub_state_changed = self._hass.bus.async_listen(EVENT_STATE_CHANGED, self._async_state_changed)
|
||||
self._unsub_initial_report = async_call_later(
|
||||
self._hass, INITIAL_REPORT_DELAY, HassJob(self._async_initial_report)
|
||||
)
|
||||
self._unsub_heartbeat_report = async_call_later(
|
||||
self._hass,
|
||||
delay=HEARTBEAT_REPORT_INTERVAL + timedelta(minutes=randint(1, 15)),
|
||||
action=HassJob(self._async_hearbeat_report),
|
||||
)
|
||||
self._unsub_discovery = async_call_later(
|
||||
self._hass, DISCOVERY_REQUEST_DELAY, HassJob(self.async_send_discovery)
|
||||
)
|
||||
|
||||
if self._track_templates:
|
||||
self._template_changes_tracker = async_track_template_result(
|
||||
self._hass,
|
||||
[TrackTemplate(t, None) for t in self._track_templates],
|
||||
self._async_template_result_changed,
|
||||
)
|
||||
self._template_changes_tracker.async_refresh()
|
||||
|
||||
return None
|
||||
|
||||
async def async_unload(self) -> None:
|
||||
"""Unload the notifier."""
|
||||
for unsub in [
|
||||
self._unsub_state_changed,
|
||||
self._unsub_initial_report,
|
||||
self._unsub_heartbeat_report,
|
||||
self._unsub_report_states,
|
||||
self._unsub_discovery,
|
||||
]:
|
||||
if unsub:
|
||||
unsub()
|
||||
|
||||
self._unsub_state_changed = None
|
||||
self._unsub_initial_report = None
|
||||
self._unsub_heartbeat_report = None
|
||||
self._unsub_report_states = None
|
||||
self._unsub_discovery = None
|
||||
|
||||
if self._template_changes_tracker is not None:
|
||||
self._template_changes_tracker.async_remove()
|
||||
self._template_changes_tracker = None
|
||||
|
||||
return None
|
||||
|
||||
async def async_send_discovery(self, *_: Any) -> None:
|
||||
"""Send notification about change of devices' parameters."""
|
||||
self._debug_log("Sending discovery request")
|
||||
request = CallbackDiscoveryRequest(payload=CallbackDiscoveryRequestPayload(user_id=self._config.user_id))
|
||||
return await self._async_send_request(f"{self._base_url}/discovery", request)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _base_url(self) -> str:
|
||||
"""Return base URL."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _request_headers(self) -> dict[str, str]:
|
||||
"""Return headers for a request."""
|
||||
pass
|
||||
|
||||
def _format_log_message(self, message: str) -> str:
|
||||
"""Format a message."""
|
||||
if self._config.extended_log:
|
||||
return f"{self._entry_data.entry.title}: {message}"
|
||||
|
||||
return message
|
||||
|
||||
def _debug_log(self, message: str) -> None:
|
||||
"""Log a debug message."""
|
||||
if self._config.extended_log:
|
||||
message = f"({self._entry_data.entry.entry_id[:6]}) {message}"
|
||||
|
||||
_LOGGER.debug(message)
|
||||
|
||||
async def _async_report_states(self, *_: Any) -> None:
|
||||
"""Send notification about device state change."""
|
||||
states: list[DeviceState] = []
|
||||
|
||||
for device_id, device_states in (await self._pending.async_get_all()).items():
|
||||
capabilities: list[CapabilityInstanceState] = []
|
||||
properties: list[PropertyInstanceState] = []
|
||||
|
||||
for c in [c for c in device_states if isinstance(c, Capability)]:
|
||||
try:
|
||||
if (capability_state := c.get_instance_state()) is not None:
|
||||
capabilities.append(capability_state)
|
||||
except APIError as e:
|
||||
_LOGGER.warning(e)
|
||||
|
||||
for p in [p for p in device_states if isinstance(p, Property)]:
|
||||
try:
|
||||
if (property_state := p.get_instance_state()) is not None:
|
||||
properties.append(property_state)
|
||||
except APIError as e:
|
||||
_LOGGER.warning(e)
|
||||
|
||||
if capabilities or properties:
|
||||
states.append(
|
||||
DeviceState(
|
||||
id=device_id,
|
||||
capabilities=capabilities or None,
|
||||
properties=properties or None,
|
||||
)
|
||||
)
|
||||
|
||||
if states:
|
||||
request = CallbackStatesRequest(
|
||||
payload=CallbackStatesRequestPayload(user_id=self._config.user_id, devices=states)
|
||||
)
|
||||
|
||||
asyncio.create_task(self._async_send_request(f"{self._base_url}/state", request))
|
||||
|
||||
if self._pending.empty:
|
||||
self._unsub_report_states = None
|
||||
else:
|
||||
self._unsub_report_states = async_call_later(
|
||||
self._hass,
|
||||
delay=0 if self._pending.time_sensitive else REPORT_STATE_WINDOW,
|
||||
action=HassJob(self._async_report_states),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
async def _async_send_request(self, url: str, request: CallbackRequest) -> None:
|
||||
"""Send a request to the url."""
|
||||
try:
|
||||
self._debug_log(f"Request: {url} (POST data: {request.as_json()})")
|
||||
|
||||
r = await self._session.post(
|
||||
url,
|
||||
headers=self._request_headers,
|
||||
data=JsonPayload(request.as_json(), dumps=lambda p: p),
|
||||
timeout=ClientTimeout(total=5),
|
||||
)
|
||||
|
||||
response_body, error_message = await r.read(), ""
|
||||
try:
|
||||
response = CallbackResponse.parse_raw(response_body)
|
||||
if response.error_message:
|
||||
error_message = response.error_message
|
||||
elif response.error_code:
|
||||
error_message = response.error_code
|
||||
except ValidationError:
|
||||
error_message = response_body.decode("utf-8").strip()[:100]
|
||||
|
||||
if r.status != 202 or error_message:
|
||||
_LOGGER.warning(
|
||||
self._format_log_message(f"State notification request failed: {error_message or r.status}")
|
||||
)
|
||||
except ClientConnectionError as e:
|
||||
_LOGGER.warning(self._format_log_message(f"State notification request failed: {e!r}"))
|
||||
except asyncio.TimeoutError as e:
|
||||
self._debug_log(f"State notification request failed: {e!r}")
|
||||
except Exception:
|
||||
_LOGGER.exception(self._format_log_message("Unexpected exception"))
|
||||
|
||||
return None
|
||||
|
||||
async def _async_template_result_changed(
|
||||
self,
|
||||
event_type: Event[EventStateChangedData] | None,
|
||||
updates: list[TrackTemplateResult],
|
||||
) -> None:
|
||||
"""Handle track template changes."""
|
||||
if event_type is None: # update during setup
|
||||
return None
|
||||
|
||||
for result in updates:
|
||||
if isinstance(result.result, TemplateError):
|
||||
_LOGGER.warning(f"Error while processing template: {result.template.template}", exc_info=result.result)
|
||||
continue
|
||||
if isinstance(result.last_result, TemplateError):
|
||||
result.last_result = None
|
||||
|
||||
for state in self._track_templates[result.template]:
|
||||
old_state = state.new_with_value(result.last_result)
|
||||
new_state = state.new_with_value(result.result)
|
||||
|
||||
for pending_state in await self._pending.async_add([new_state], [old_state]):
|
||||
self._debug_log(
|
||||
f"State report with value '{pending_state.get_value()}' scheduled for {pending_state!r}"
|
||||
)
|
||||
|
||||
return self._schedule_report_states()
|
||||
|
||||
async def _async_state_changed(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle state changes."""
|
||||
entity_id = str(event.data.get(ATTR_ENTITY_ID))
|
||||
old_state: State | None = event.data.get("old_state")
|
||||
new_state: State | None = event.data.get("new_state")
|
||||
|
||||
if not new_state:
|
||||
return None
|
||||
|
||||
old_device_states: list[ReportableDeviceState] = []
|
||||
new_device_states: list[ReportableDeviceState] = []
|
||||
|
||||
for device_id, cls in self._track_entity_states.get(entity_id, []):
|
||||
new_device_states.append(cls(self._hass, self._entry_data, device_id, new_state))
|
||||
if old_state:
|
||||
old_device_states.append(cls(self._hass, self._entry_data, device_id, old_state))
|
||||
|
||||
new_device = Device(self._hass, self._entry_data, entity_id, new_state)
|
||||
if new_device.should_expose:
|
||||
new_device_states.extend(new_device.get_state_capabilities())
|
||||
new_device_states.extend(new_device.get_state_properties())
|
||||
|
||||
if old_state:
|
||||
old_device = Device(self._hass, self._entry_data, entity_id, old_state)
|
||||
old_device_states.extend(old_device.get_state_capabilities())
|
||||
old_device_states.extend(old_device.get_state_properties())
|
||||
|
||||
for pending_state in await self._pending.async_add(new_device_states, old_device_states):
|
||||
self._debug_log(f"State report with value '{pending_state.get_value()}' scheduled for {pending_state!r}")
|
||||
|
||||
return self._schedule_report_states()
|
||||
|
||||
async def _async_initial_report(self, *_: Any) -> None:
|
||||
"""Schedule initial report."""
|
||||
self._debug_log("Reporting initial states")
|
||||
for state in self._hass.states.async_all():
|
||||
device = Device(self._hass, self._entry_data, state.entity_id, state)
|
||||
if device.should_expose:
|
||||
await self._pending.async_add(device.get_capabilities(), [])
|
||||
await self._pending.async_add([p for p in device.get_properties() if p.heartbeat_report], [])
|
||||
|
||||
return self._schedule_report_states()
|
||||
|
||||
async def _async_hearbeat_report(self, *_: Any) -> None:
|
||||
"""Schedule periodical state report."""
|
||||
self._debug_log("Reporting states (heartbeat)")
|
||||
for state in self._hass.states.async_all():
|
||||
device = Device(self._hass, self._entry_data, state.entity_id, state)
|
||||
if device.should_expose:
|
||||
await self._pending.async_add([p for p in device.get_properties() if p.heartbeat_report], [])
|
||||
|
||||
self._unsub_heartbeat_report = async_call_later(
|
||||
self._hass,
|
||||
delay=HEARTBEAT_REPORT_INTERVAL,
|
||||
action=HassJob(self._async_hearbeat_report),
|
||||
)
|
||||
return self._schedule_report_states()
|
||||
|
||||
def _schedule_report_states(self) -> None:
|
||||
"""Schedule run report states job if there are pending states."""
|
||||
if self._pending.empty or self._unsub_report_states:
|
||||
return None
|
||||
|
||||
self._unsub_report_states = async_call_later(
|
||||
self._hass,
|
||||
delay=0 if self._pending.time_sensitive else REPORT_STATE_WINDOW,
|
||||
action=HassJob(self._async_report_states),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class YandexDirectNotifier(Notifier):
|
||||
"""Notifier for direct connection."""
|
||||
|
||||
@property
|
||||
def _base_url(self) -> str:
|
||||
"""Return base URL."""
|
||||
return f"https://dialogs.yandex.net/api/v1/skills/{self._config.skill_id}/callback"
|
||||
|
||||
@property
|
||||
def _request_headers(self) -> dict[str, str]:
|
||||
"""Return headers for a request."""
|
||||
return {hdrs.AUTHORIZATION: f"OAuth {self._config.token}"}
|
||||
|
||||
|
||||
class CloudNotifier(Notifier):
|
||||
"""Notifier for cloud connections."""
|
||||
|
||||
@property
|
||||
def _base_url(self) -> str:
|
||||
"""Return base URL."""
|
||||
return f"{CLOUD_BASE_URL}/api/home_assistant/v2/callback/{self._config.platform}"
|
||||
|
||||
@property
|
||||
def _request_headers(self) -> dict[str, str]:
|
||||
"""Return headers for a request."""
|
||||
return {
|
||||
hdrs.AUTHORIZATION: f"Bearer {self._config.token}",
|
||||
hdrs.USER_AGENT: f"{SERVER_SOFTWARE} {DOMAIN}/{self._entry_data.component_version}",
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Implement the Yandex Smart Home base device property."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self, runtime_checkable
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_CLASS
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
|
||||
from .helpers import ListRegistry
|
||||
from .schema import (
|
||||
PropertyDescription,
|
||||
PropertyInstance,
|
||||
PropertyInstanceState,
|
||||
PropertyInstanceStateValue,
|
||||
PropertyParameters,
|
||||
PropertyType,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entry_data import ConfigEntryData
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Property(Protocol):
|
||||
"""Base class for a device property."""
|
||||
|
||||
device_id: str
|
||||
type: PropertyType
|
||||
instance: PropertyInstance
|
||||
|
||||
_hass: HomeAssistant
|
||||
_entry_data: ConfigEntryData
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
...
|
||||
|
||||
@property
|
||||
def retrievable(self) -> bool:
|
||||
"""Test if the property can return the current value."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def reportable(self) -> bool:
|
||||
"""Test if the property can report value changes."""
|
||||
return self._entry_data.is_reporting_states
|
||||
|
||||
@property
|
||||
def heartbeat_report(self) -> bool:
|
||||
"""Test if property value should be reported on startup and periodically."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def time_sensitive(self) -> bool:
|
||||
"""Test if value changes should be reported immediately."""
|
||||
return False
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def parameters(self) -> PropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_description(self) -> PropertyDescription:
|
||||
"""Return a description for a device list request."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_value(self) -> Any:
|
||||
"""Return the current property value."""
|
||||
...
|
||||
|
||||
def get_instance_state(self) -> PropertyInstanceState | None:
|
||||
"""Return a state for a state query request."""
|
||||
if (value := self.get_value()) is not None:
|
||||
return PropertyInstanceState(
|
||||
type=self.type, state=PropertyInstanceStateValue(instance=self.instance, value=value)
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def check_value_change(self, other: Self | None) -> bool:
|
||||
"""Test if the property value differs from other property."""
|
||||
...
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return string representation."""
|
||||
return f"instance {self.instance} of {self.type.short} property of {self.device_id}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return the representation."""
|
||||
return (
|
||||
f"<{self.__class__.__name__}"
|
||||
f" device_id={self.device_id }"
|
||||
f" type={self.type}"
|
||||
f" instance={self.instance}"
|
||||
f">"
|
||||
)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
"""Compare properties."""
|
||||
return bool(
|
||||
isinstance(other, Property)
|
||||
and self.type == other.type
|
||||
and self.instance == other.instance
|
||||
and self.device_id == other.device_id
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StateProperty(Property, Protocol):
|
||||
"""Base class for a device property based on the state."""
|
||||
|
||||
state: State
|
||||
device_id: str
|
||||
|
||||
_hass: HomeAssistant
|
||||
_entry_data: ConfigEntryData
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry_data: ConfigEntryData, device_id: str, state: State):
|
||||
"""Initialize a property for the state."""
|
||||
self._hass = hass
|
||||
self._entry_data = entry_data
|
||||
|
||||
self.state = state
|
||||
self.device_id = device_id
|
||||
|
||||
@property
|
||||
def _state_device_class(self) -> str | None:
|
||||
"""Return state device class."""
|
||||
return self.state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
|
||||
|
||||
STATE_PROPERTIES_REGISTRY = ListRegistry[type[StateProperty]]()
|
||||
@@ -0,0 +1,489 @@
|
||||
"""Implement the Yandex Smart Home custom properties."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Protocol, Self, cast
|
||||
|
||||
from homeassistant.components import binary_sensor, event
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
|
||||
|
||||
from .const import (
|
||||
CONF_ENTITY_PROPERTY_ATTRIBUTE,
|
||||
CONF_ENTITY_PROPERTY_ENTITY,
|
||||
CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT,
|
||||
CONF_ENTITY_PROPERTY_TYPE,
|
||||
CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT,
|
||||
CONF_ENTITY_PROPERTY_VALUE_TEMPLATE,
|
||||
PropertyInstanceType,
|
||||
)
|
||||
from .helpers import APIError, DictRegistry
|
||||
from .property import Property
|
||||
from .property_event import (
|
||||
BatteryLevelEventProperty,
|
||||
ButtonPressEventProperty,
|
||||
EventPlatformProperty,
|
||||
EventProperty,
|
||||
FoodLevelEventProperty,
|
||||
GasEventProperty,
|
||||
MotionEventProperty,
|
||||
OpenEventProperty,
|
||||
ReactiveEventProperty,
|
||||
SensorEventProperty,
|
||||
SmokeEventProperty,
|
||||
VibrationEventProperty,
|
||||
WaterLeakEventProperty,
|
||||
WaterLevelEventProperty,
|
||||
)
|
||||
from .property_float import (
|
||||
BatteryLevelPercentageProperty,
|
||||
CO2LevelProperty,
|
||||
ElectricCurrentProperty,
|
||||
ElectricityMeterProperty,
|
||||
ElectricPowerProperty,
|
||||
FloatProperty,
|
||||
FoodLevelPercentageProperty,
|
||||
GasMeterProperty,
|
||||
HeatMeterProperty,
|
||||
HumidityProperty,
|
||||
IlluminationProperty,
|
||||
MeterProperty,
|
||||
PM1DensityProperty,
|
||||
PM10DensityProperty,
|
||||
PM25DensityProperty,
|
||||
PressureProperty,
|
||||
TemperatureProperty,
|
||||
TVOCConcentrationProperty,
|
||||
VoltageProperty,
|
||||
WaterLevelPercentageProperty,
|
||||
WaterMeterProperty,
|
||||
)
|
||||
from .schema import PropertyType, ResponseCode
|
||||
from .unit_conversion import UnitOfPressure, UnitOfTemperature
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entry_data import ConfigEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomProperty(Property, Protocol):
|
||||
"""Base class for a property that user can set up using yaml configuration."""
|
||||
|
||||
device_id: str
|
||||
|
||||
_hass: HomeAssistant
|
||||
_entry_data: ConfigEntryData
|
||||
_config: ConfigType
|
||||
_value_template: Template
|
||||
_value: Any | UndefinedType
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_data: ConfigEntryData,
|
||||
config: ConfigType,
|
||||
device_id: str,
|
||||
value_template: Template,
|
||||
value: Any | UndefinedType = UNDEFINED,
|
||||
):
|
||||
"""Initialize a custom property."""
|
||||
self._hass = hass
|
||||
self._entry_data = entry_data
|
||||
self._config = config
|
||||
self._value_template = value_template
|
||||
self._value = value
|
||||
|
||||
self.device_id = device_id
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return True
|
||||
|
||||
def _get_native_value(self) -> str:
|
||||
"""Return the current property value without conversion."""
|
||||
if self._value is not UNDEFINED:
|
||||
return str(self._value).strip()
|
||||
|
||||
try:
|
||||
return str(self._value_template.async_render()).strip()
|
||||
except TemplateError as exc:
|
||||
raise APIError(ResponseCode.INVALID_VALUE, f"Failed to get current value for {self}: {exc!r}")
|
||||
|
||||
def new_with_value(self, value: Any) -> Self:
|
||||
"""Return copy of the state with new value."""
|
||||
return self.__class__(
|
||||
self._hass,
|
||||
self._entry_data,
|
||||
self._config,
|
||||
self.device_id,
|
||||
self._value_template,
|
||||
value,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return the representation."""
|
||||
return (
|
||||
f"<{self.__class__.__name__}"
|
||||
f" device_id={self.device_id }"
|
||||
f" instance={self.instance}"
|
||||
f" value_template={self._value_template}"
|
||||
f" value={self._value}"
|
||||
f">"
|
||||
)
|
||||
|
||||
|
||||
class EventPlatformCustomProperty(EventPlatformProperty, Protocol):
|
||||
"Base class for an event property of event platform that user can set up using yaml configuration."
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return True
|
||||
|
||||
|
||||
class CustomEventProperty(CustomProperty, EventProperty[Any], Protocol):
|
||||
"""Base class for an event property that user can set up using yaml configuration."""
|
||||
|
||||
|
||||
EVENT_PROPERTIES_REGISTRY = DictRegistry[type[CustomEventProperty]]()
|
||||
|
||||
|
||||
@EVENT_PROPERTIES_REGISTRY.register
|
||||
class OpenCustomEventProperty(SensorEventProperty, OpenEventProperty, CustomEventProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PROPERTIES_REGISTRY.register
|
||||
class MotionCustomEventProperty(SensorEventProperty, MotionEventProperty, CustomEventProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PROPERTIES_REGISTRY.register
|
||||
class GasCustomEventProperty(SensorEventProperty, GasEventProperty, CustomEventProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PROPERTIES_REGISTRY.register
|
||||
class SmokeCustomEventProperty(SensorEventProperty, SmokeEventProperty, CustomEventProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PROPERTIES_REGISTRY.register
|
||||
class BatteryLevelCustomEventProperty(SensorEventProperty, BatteryLevelEventProperty, CustomEventProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PROPERTIES_REGISTRY.register
|
||||
class FoodLevelCustomEventProperty(SensorEventProperty, FoodLevelEventProperty, CustomEventProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PROPERTIES_REGISTRY.register
|
||||
class WaterLevelCustomEventProperty(SensorEventProperty, WaterLevelEventProperty, CustomEventProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PROPERTIES_REGISTRY.register
|
||||
class WaterLeakCustomEventProperty(SensorEventProperty, WaterLeakEventProperty, CustomEventProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PROPERTIES_REGISTRY.register
|
||||
class ButtonPressCustomEventProperty(ReactiveEventProperty, ButtonPressEventProperty, CustomEventProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PROPERTIES_REGISTRY.register
|
||||
class VibrationCustomEventProperty(ReactiveEventProperty, VibrationEventProperty, CustomEventProperty):
|
||||
pass
|
||||
|
||||
|
||||
EVENT_PLATFORM_PROPERTIES_REGISTRY = DictRegistry[type[EventPlatformCustomProperty]]()
|
||||
|
||||
|
||||
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
|
||||
class OpenEventPlatformCustomProperty(OpenEventProperty, EventPlatformCustomProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
|
||||
class MotionEventPlatformCustomProperty(MotionEventProperty, EventPlatformCustomProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
|
||||
class GasEventPlatformCustomProperty(GasEventProperty, EventPlatformCustomProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
|
||||
class SmokeEventPlatformCustomProperty(SmokeEventProperty, EventPlatformCustomProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
|
||||
class BatteryLevelEventPlatformCustomProperty(BatteryLevelEventProperty, EventPlatformCustomProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
|
||||
class FoodLevelEventPlatformCustomProperty(FoodLevelEventProperty, EventPlatformCustomProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
|
||||
class WaterLevelEventPlatformCustomProperty(WaterLevelEventProperty, EventPlatformCustomProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
|
||||
class WaterLeakEventPlatformCustomProperty(WaterLeakEventProperty, EventPlatformCustomProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
|
||||
class ButtonPressEventPlatformCustomProperty(ButtonPressEventProperty, EventPlatformCustomProperty):
|
||||
pass
|
||||
|
||||
|
||||
@EVENT_PLATFORM_PROPERTIES_REGISTRY.register
|
||||
class VibrationEventPlatformCustomProperty(VibrationEventProperty, EventPlatformCustomProperty):
|
||||
pass
|
||||
|
||||
|
||||
class CustomFloatProperty(CustomProperty, FloatProperty, Protocol):
|
||||
"""Base class for a float property that user can set up using yaml configuration."""
|
||||
|
||||
def _get_native_value(self) -> str:
|
||||
"""Return the current property value without conversion."""
|
||||
return super()._get_native_value()
|
||||
|
||||
@cached_property
|
||||
def _native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit the native value is expressed in."""
|
||||
if unit := self._config.get(CONF_ENTITY_PROPERTY_UNIT_OF_MEASUREMENT):
|
||||
return str(unit)
|
||||
|
||||
for s in ("state_attr(", ".attributes"):
|
||||
if s in self._value_template.template:
|
||||
return None
|
||||
|
||||
info = self._value_template.async_render_to_info()
|
||||
if len(info.entities) == 1:
|
||||
entity_id = next(iter(info.entities))
|
||||
state = self._hass.states.get(entity_id)
|
||||
if state:
|
||||
return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
FLOAT_PROPERTIES_REGISTRY = DictRegistry[type[CustomFloatProperty]]()
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class TemperatureCustomFloatProperty(TemperatureProperty, CustomFloatProperty):
|
||||
@property
|
||||
def unit_of_measurement(self) -> UnitOfTemperature:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
if unit := self._config.get(CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT):
|
||||
return UnitOfTemperature(unit)
|
||||
|
||||
return super().unit_of_measurement
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class HumidityCustomFloatProperty(HumidityProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class PressureCustomFloatProperty(PressureProperty, CustomFloatProperty):
|
||||
@property
|
||||
def unit_of_measurement(self) -> UnitOfPressure:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
if unit := self._config.get(CONF_ENTITY_PROPERTY_TARGET_UNIT_OF_MEASUREMENT):
|
||||
return UnitOfPressure(unit)
|
||||
|
||||
return super().unit_of_measurement
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class IlluminationCustomFloatProperty(IlluminationProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class FoodLevelCustomFloatProperty(FoodLevelPercentageProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class WaterLevelCustomFloatProperty(WaterLevelPercentageProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class CO2LevelCustomFloatProperty(CO2LevelProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class MeterCustomFloatProperty(MeterProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class ElectricityMeterCustomFloatProperty(ElectricityMeterProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class GasMeterCustomFloatProperty(GasMeterProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class HeatMeterCustomFloatProperty(HeatMeterProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class WaterMeterCustomFloatProperty(WaterMeterProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class PM1DensityCustomFloatProperty(PM1DensityProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class PM25DensityCustomFloatProperty(PM25DensityProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class PM10DensityCustomFloatProperty(PM10DensityProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class TVOCConcentrationCustomFloatProperty(TVOCConcentrationProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class VoltageCustomFloatProperty(VoltageProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class ElectricCurrentCustomFloatProperty(ElectricCurrentProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class ElectricPowerCustomFloatProperty(ElectricPowerProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
@FLOAT_PROPERTIES_REGISTRY.register
|
||||
class BatteryLevelCustomFloatProperty(BatteryLevelPercentageProperty, CustomFloatProperty):
|
||||
pass
|
||||
|
||||
|
||||
def get_custom_property(
|
||||
hass: HomeAssistant, entry_data: ConfigEntryData, config: ConfigType, device_id: str
|
||||
) -> CustomProperty | None:
|
||||
"""Return initialized custom property based on property configuration."""
|
||||
if _is_event_platform_entity(config.get(CONF_ENTITY_PROPERTY_ENTITY)):
|
||||
return None
|
||||
|
||||
cls: type[CustomEventProperty] | type[CustomFloatProperty]
|
||||
property_type: str = config[CONF_ENTITY_PROPERTY_TYPE]
|
||||
value_template = get_value_template(hass, device_id, config)
|
||||
vault_template_info = value_template.async_render_to_info()
|
||||
|
||||
if property_type.startswith(f"{PropertyInstanceType.EVENT}."):
|
||||
cls = EVENT_PROPERTIES_REGISTRY[property_type.split(".", 1)[1]]
|
||||
elif property_type.startswith(f"{PropertyInstanceType.FLOAT}."):
|
||||
cls = FLOAT_PROPERTIES_REGISTRY[property_type.split(".", 1)[1]]
|
||||
else:
|
||||
instance = property_type
|
||||
if instance not in FLOAT_PROPERTIES_REGISTRY and instance in EVENT_PROPERTIES_REGISTRY:
|
||||
property_type = PropertyType.EVENT
|
||||
else:
|
||||
property_type = PropertyType.FLOAT
|
||||
|
||||
if len(vault_template_info.entities) == 1:
|
||||
entity_id = next(iter(vault_template_info.entities))
|
||||
domain, _ = split_entity_id(entity_id)
|
||||
|
||||
if domain == binary_sensor.DOMAIN:
|
||||
if instance not in EVENT_PROPERTIES_REGISTRY:
|
||||
raise APIError(
|
||||
ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE,
|
||||
f"Unsupported entity {entity_id} for {instance} property of {device_id}",
|
||||
)
|
||||
|
||||
property_type = PropertyType.EVENT
|
||||
|
||||
if property_type == PropertyType.EVENT:
|
||||
cls = EVENT_PROPERTIES_REGISTRY[instance]
|
||||
else:
|
||||
cls = FLOAT_PROPERTIES_REGISTRY[instance]
|
||||
|
||||
for entity_id in vault_template_info.entities:
|
||||
if _is_event_platform_entity(entity_id):
|
||||
_LOGGER.warning(f"Entity {entity_id} is not supported in value_template, use state_entity instead")
|
||||
|
||||
return cls(hass, entry_data, config, device_id, value_template)
|
||||
|
||||
|
||||
def get_event_platform_custom_property_type(config: ConfigType) -> type[EventPlatformCustomProperty] | None:
|
||||
"""Return the class type of event platform custom property based on property configuration."""
|
||||
entity_id = config.get(CONF_ENTITY_PROPERTY_ENTITY)
|
||||
if not _is_event_platform_entity(entity_id):
|
||||
return None
|
||||
|
||||
property_type: str = config[CONF_ENTITY_PROPERTY_TYPE]
|
||||
if property_type.startswith(f"{PropertyInstanceType.EVENT}."):
|
||||
return EVENT_PLATFORM_PROPERTIES_REGISTRY[property_type.split(".", 1)[1]]
|
||||
|
||||
try:
|
||||
return EVENT_PLATFORM_PROPERTIES_REGISTRY[property_type]
|
||||
except KeyError:
|
||||
_LOGGER.warning(f"Property type {property_type} is not supported for entity {entity_id}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_value_template(hass: HomeAssistant, device_id: str, property_config: ConfigType) -> Template:
|
||||
"""Return property value template from property configuration."""
|
||||
if template := property_config.get(CONF_ENTITY_PROPERTY_VALUE_TEMPLATE):
|
||||
return cast(Template, template)
|
||||
|
||||
entity_id = property_config.get(CONF_ENTITY_PROPERTY_ENTITY, device_id)
|
||||
attribute = property_config.get(CONF_ENTITY_PROPERTY_ATTRIBUTE)
|
||||
|
||||
if attribute:
|
||||
return Template("{{ state_attr('%s', '%s') }}" % (entity_id, attribute), hass)
|
||||
|
||||
return Template("{{ states('%s') }}" % entity_id, hass)
|
||||
|
||||
|
||||
def _is_event_platform_entity(entity_id: str | None) -> bool:
|
||||
"""Check if the entity provided by event platform."""
|
||||
if not entity_id:
|
||||
return False
|
||||
|
||||
domain, _ = split_entity_id(entity_id)
|
||||
return domain == event.DOMAIN
|
||||
@@ -0,0 +1,557 @@
|
||||
"""Implement the Yandex Smart Home event properties."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from functools import cached_property
|
||||
from itertools import chain
|
||||
import logging
|
||||
from typing import Any, Protocol, Self, cast
|
||||
|
||||
from homeassistant.components import binary_sensor, sensor
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.event import ATTR_EVENT_TYPE, DOMAIN as EVENT_DOMAIN, EventDeviceClass
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
STATE_CLOSED,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_OPEN,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_ENTITY_EVENT_MAP, STATE_EMPTY, STATE_NONE, STATE_NONE_UI, XGW3DeviceClass
|
||||
from .property import STATE_PROPERTIES_REGISTRY, Property, StateProperty
|
||||
from .schema import (
|
||||
BatteryLevelEventPropertyParameters,
|
||||
BatteryLevelInstanceEvent,
|
||||
ButtonEventPropertyParameters,
|
||||
ButtonInstanceEvent,
|
||||
EventInstanceEvent,
|
||||
EventInstanceEventT,
|
||||
EventPropertyDescription,
|
||||
EventPropertyInstance,
|
||||
EventPropertyParameters,
|
||||
FoodLevelEventPropertyParameters,
|
||||
FoodLevelInstanceEvent,
|
||||
GasEventPropertyParameters,
|
||||
GasInstanceEvent,
|
||||
MotionEventPropertyParameters,
|
||||
MotionInstanceEvent,
|
||||
OpenEventPropertyParameters,
|
||||
OpenInstanceEvent,
|
||||
PropertyType,
|
||||
SmokeEventPropertyParameters,
|
||||
SmokeInstanceEvent,
|
||||
VibrationEventPropertyParameters,
|
||||
VibrationInstanceEvent,
|
||||
WaterLeakEventPropertyParameters,
|
||||
WaterLeakInstanceEvent,
|
||||
WaterLevelEventPropertyParameters,
|
||||
WaterLevelInstanceEvent,
|
||||
)
|
||||
from .schema.property_event import get_event_class_for_instance
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_BOOLEAN_TRUE = ["yes", "true", "1", STATE_ON]
|
||||
_BOOLEAN_FALSE = ["no", "false", "0", STATE_OFF]
|
||||
|
||||
type EventMapT[EventInstanceEventT] = dict[EventInstanceEventT, list[str]]
|
||||
|
||||
|
||||
class EventProperty(Property, Protocol[EventInstanceEventT]):
|
||||
"""Base class for event properties."""
|
||||
|
||||
type: PropertyType = PropertyType.EVENT
|
||||
instance: EventPropertyInstance
|
||||
|
||||
_event_map_default: EventMapT[EventInstanceEventT] = {}
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def parameters(self) -> EventPropertyParameters[EventInstanceEventT]:
|
||||
"""Return parameters for a devices list request."""
|
||||
...
|
||||
|
||||
def get_description(self) -> EventPropertyDescription:
|
||||
"""Return a description for a device list request."""
|
||||
return EventPropertyDescription(
|
||||
retrievable=self.retrievable, reportable=self.reportable, parameters=self.parameters
|
||||
)
|
||||
|
||||
@property
|
||||
def heartbeat_report(self) -> bool:
|
||||
"""Test if property value should be reported on startup and periodically."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def time_sensitive(self) -> bool:
|
||||
"""Test if value changes should be reported immediately."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def event_map(self) -> dict[EventInstanceEventT, list[str]]:
|
||||
"""Return an event mapping between Yandex and HA."""
|
||||
return self.event_map_config or self._event_map_default
|
||||
|
||||
@property
|
||||
def event_map_config(self) -> dict[EventInstanceEventT, list[str]]:
|
||||
"""Return an event mapping from a entity configuration."""
|
||||
if CONF_ENTITY_EVENT_MAP in self._entity_config:
|
||||
event_cls = get_event_class_for_instance(self.instance)
|
||||
return cast(
|
||||
dict[EventInstanceEventT, list[str]],
|
||||
{event_cls(k): v for k, v in self._entity_config[CONF_ENTITY_EVENT_MAP].get(self.instance, {}).items()},
|
||||
)
|
||||
|
||||
return {}
|
||||
|
||||
def get_value(self) -> EventInstanceEvent | None:
|
||||
"""Return the current property value."""
|
||||
value = str(self._get_native_value()).lower()
|
||||
|
||||
if value in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE, STATE_NONE_UI, STATE_EMPTY):
|
||||
return None
|
||||
|
||||
for event, values in self.event_map.items():
|
||||
if value in values:
|
||||
return event
|
||||
|
||||
_LOGGER.debug(f"Unknown event {value} for instance {self.instance} of {self.device_id}")
|
||||
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def _entity_config(self) -> ConfigType:
|
||||
"""Return additional configuration for the device."""
|
||||
return self._entry_data.get_entity_config(self.device_id)
|
||||
|
||||
@abstractmethod
|
||||
def _get_native_value(self) -> str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
...
|
||||
|
||||
@cached_property
|
||||
def _supported_native_values(self) -> list[str]:
|
||||
"""Return a list of supported native values."""
|
||||
return list(chain.from_iterable(self.event_map.values()))
|
||||
|
||||
|
||||
class SensorEventProperty(EventProperty[Any]):
|
||||
"""Represent a binary-like property with stable current value."""
|
||||
|
||||
def check_value_change(self, other: Self | None) -> bool:
|
||||
"""Test if the property value differs from other property."""
|
||||
if other is None:
|
||||
return False
|
||||
|
||||
value, other_value = self.get_value(), other.get_value()
|
||||
if value is None or other_value is None:
|
||||
return False
|
||||
|
||||
return bool(value != other_value)
|
||||
|
||||
|
||||
class ReactiveEventProperty(EventProperty[Any], Protocol):
|
||||
"""Represent a button-like event property (sensor and binary_sensor platforms)."""
|
||||
|
||||
def check_value_change(self, other: Self | None) -> bool:
|
||||
"""Test if the property value differs from other property."""
|
||||
value = self.get_value()
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if other is None:
|
||||
return True
|
||||
|
||||
return value != other.get_value()
|
||||
|
||||
|
||||
class OpenEventProperty(EventProperty[OpenInstanceEvent], Protocol):
|
||||
"""Base class for event property that detect opening of something."""
|
||||
|
||||
instance: EventPropertyInstance = EventPropertyInstance.OPEN
|
||||
|
||||
_event_map_default: EventMapT[OpenInstanceEvent] = {
|
||||
OpenInstanceEvent.OPENED: _BOOLEAN_TRUE + [STATE_OPEN],
|
||||
OpenInstanceEvent.CLOSED: _BOOLEAN_FALSE + [STATE_CLOSED],
|
||||
}
|
||||
|
||||
@property
|
||||
def parameters(self) -> OpenEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return OpenEventPropertyParameters()
|
||||
|
||||
|
||||
class MotionEventProperty(EventProperty[MotionInstanceEvent], Protocol):
|
||||
"""Base class for event property that detect motion, presence or occupancy."""
|
||||
|
||||
instance: EventPropertyInstance = EventPropertyInstance.MOTION
|
||||
|
||||
_event_map_default: EventMapT[MotionInstanceEvent] = {
|
||||
MotionInstanceEvent.DETECTED: _BOOLEAN_TRUE + ["motion", "motion_detected"],
|
||||
MotionInstanceEvent.NOT_DETECTED: _BOOLEAN_FALSE,
|
||||
}
|
||||
|
||||
@property
|
||||
def parameters(self) -> MotionEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return MotionEventPropertyParameters()
|
||||
|
||||
|
||||
class GasEventProperty(EventProperty[GasInstanceEvent], Protocol):
|
||||
"""Base class for event property that detect gas presence."""
|
||||
|
||||
instance: EventPropertyInstance = EventPropertyInstance.GAS
|
||||
|
||||
_event_map_default: EventMapT[GasInstanceEvent] = {
|
||||
GasInstanceEvent.DETECTED: _BOOLEAN_TRUE,
|
||||
GasInstanceEvent.NOT_DETECTED: _BOOLEAN_FALSE,
|
||||
GasInstanceEvent.HIGH: ["high"],
|
||||
}
|
||||
|
||||
@property
|
||||
def parameters(self) -> GasEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return GasEventPropertyParameters()
|
||||
|
||||
|
||||
class SmokeEventProperty(EventProperty[SmokeInstanceEvent], Protocol):
|
||||
"""Base class for event property that detect smoke presence."""
|
||||
|
||||
instance: EventPropertyInstance = EventPropertyInstance.SMOKE
|
||||
|
||||
_event_map_default: EventMapT[SmokeInstanceEvent] = {
|
||||
SmokeInstanceEvent.DETECTED: _BOOLEAN_TRUE,
|
||||
SmokeInstanceEvent.NOT_DETECTED: _BOOLEAN_FALSE,
|
||||
SmokeInstanceEvent.HIGH: ["high"],
|
||||
}
|
||||
|
||||
@property
|
||||
def parameters(self) -> SmokeEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return SmokeEventPropertyParameters()
|
||||
|
||||
|
||||
class BatteryLevelEventProperty(EventProperty[BatteryLevelInstanceEvent], Protocol):
|
||||
"""Base class for event property that detect low level of a battery."""
|
||||
|
||||
instance: EventPropertyInstance = EventPropertyInstance.BATTERY_LEVEL
|
||||
|
||||
_event_map_default: EventMapT[BatteryLevelInstanceEvent] = {
|
||||
BatteryLevelInstanceEvent.LOW: _BOOLEAN_TRUE + ["low"],
|
||||
BatteryLevelInstanceEvent.NORMAL: _BOOLEAN_FALSE + ["normal"],
|
||||
BatteryLevelInstanceEvent.HIGH: ["high"],
|
||||
}
|
||||
|
||||
@property
|
||||
def parameters(self) -> BatteryLevelEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return BatteryLevelEventPropertyParameters()
|
||||
|
||||
|
||||
class FoodLevelEventProperty(EventProperty[FoodLevelInstanceEvent], Protocol):
|
||||
"""Base class for event property that detect food level."""
|
||||
|
||||
instance: EventPropertyInstance = EventPropertyInstance.FOOD_LEVEL
|
||||
|
||||
_event_map_default: EventMapT[FoodLevelInstanceEvent] = {
|
||||
FoodLevelInstanceEvent.EMPTY: ["empty"],
|
||||
FoodLevelInstanceEvent.LOW: ["low"],
|
||||
FoodLevelInstanceEvent.NORMAL: ["normal"],
|
||||
}
|
||||
|
||||
@property
|
||||
def parameters(self) -> FoodLevelEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return FoodLevelEventPropertyParameters()
|
||||
|
||||
|
||||
class WaterLevelEventProperty(EventProperty[WaterLevelInstanceEvent], Protocol):
|
||||
"""Base class for event property that detect low level of water."""
|
||||
|
||||
instance: EventPropertyInstance = EventPropertyInstance.WATER_LEVEL
|
||||
|
||||
_event_map_default: EventMapT[WaterLevelInstanceEvent] = {
|
||||
WaterLevelInstanceEvent.EMPTY: ["empty"],
|
||||
WaterLevelInstanceEvent.LOW: _BOOLEAN_TRUE + ["low"],
|
||||
WaterLevelInstanceEvent.NORMAL: _BOOLEAN_FALSE + ["normal"],
|
||||
}
|
||||
|
||||
@property
|
||||
def parameters(self) -> WaterLevelEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return WaterLevelEventPropertyParameters()
|
||||
|
||||
|
||||
class WaterLeakEventProperty(EventProperty[WaterLeakInstanceEvent], Protocol):
|
||||
"""Base class for event property that detect water leakage."""
|
||||
|
||||
instance: EventPropertyInstance = EventPropertyInstance.WATER_LEAK
|
||||
|
||||
_event_map_default: EventMapT[WaterLeakInstanceEvent] = {
|
||||
WaterLeakInstanceEvent.DRY: _BOOLEAN_FALSE + ["dry"],
|
||||
WaterLeakInstanceEvent.LEAK: _BOOLEAN_TRUE + ["leak"],
|
||||
}
|
||||
|
||||
@property
|
||||
def parameters(self) -> WaterLeakEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return WaterLeakEventPropertyParameters()
|
||||
|
||||
|
||||
class ButtonPressEventProperty(EventProperty[ButtonInstanceEvent], Protocol):
|
||||
"""Base class for event property that detect a button interaction."""
|
||||
|
||||
instance: EventPropertyInstance = EventPropertyInstance.BUTTON
|
||||
|
||||
_event_map_default: EventMapT[ButtonInstanceEvent] = {
|
||||
ButtonInstanceEvent.CLICK: ["click", "single", "press", "pressed"],
|
||||
ButtonInstanceEvent.DOUBLE_CLICK: [
|
||||
"double_click",
|
||||
"double_press",
|
||||
"double",
|
||||
"many",
|
||||
"quadruple",
|
||||
"triple",
|
||||
"triple_press",
|
||||
"long_triple_press",
|
||||
"long_double_press",
|
||||
],
|
||||
ButtonInstanceEvent.LONG_PRESS: [
|
||||
"hold",
|
||||
"long_click_press",
|
||||
"long_click",
|
||||
"long_press",
|
||||
"long",
|
||||
],
|
||||
}
|
||||
|
||||
@property
|
||||
def retrievable(self) -> bool:
|
||||
"""Test if the property can return the current value."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def parameters(self) -> ButtonEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return ButtonEventPropertyParameters()
|
||||
|
||||
|
||||
class VibrationEventProperty(EventProperty[VibrationInstanceEvent], Protocol):
|
||||
"""Base class for event property that detect vibration."""
|
||||
|
||||
instance: EventPropertyInstance = EventPropertyInstance.VIBRATION
|
||||
|
||||
_event_map_default: EventMapT[VibrationInstanceEvent] = {
|
||||
VibrationInstanceEvent.VIBRATION: _BOOLEAN_TRUE
|
||||
+ [
|
||||
"vibration",
|
||||
"vibrate",
|
||||
"actively",
|
||||
"move",
|
||||
"tap_twice",
|
||||
"shake_air",
|
||||
"swing",
|
||||
],
|
||||
VibrationInstanceEvent.TILT: ["tilt", "flip90", "flip180", "rotate"],
|
||||
VibrationInstanceEvent.FALL: ["fall", "free_fall", "drop"],
|
||||
}
|
||||
|
||||
@property
|
||||
def retrievable(self) -> bool:
|
||||
"""Test if the property can return the current value."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def parameters(self) -> VibrationEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return VibrationEventPropertyParameters()
|
||||
|
||||
|
||||
class StateEventProperty(StateProperty, EventProperty[Any], Protocol):
|
||||
"""Base class for a event property based on the state."""
|
||||
|
||||
def _get_native_value(self) -> str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
return self.state.state
|
||||
|
||||
|
||||
class EventPlatformProperty(StateProperty, EventProperty[Any], Protocol):
|
||||
"""Base class for a event property based on the state of an event platform entity."""
|
||||
|
||||
@property
|
||||
def retrievable(self) -> bool:
|
||||
"""Test if the property can return the current value."""
|
||||
return False
|
||||
|
||||
def check_value_change(self, other: Self | None) -> bool:
|
||||
"""Test if the property value differs from other property."""
|
||||
value = self.get_value()
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if other is None:
|
||||
return True
|
||||
|
||||
if self.state.state == other.state.state:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _get_native_value(self) -> str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
return self.state.attributes.get(ATTR_EVENT_TYPE)
|
||||
|
||||
|
||||
class OpenStateEventProperty(StateEventProperty, SensorEventProperty, OpenEventProperty):
|
||||
"""Represents the state event property that detect opening of something."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return self.state.domain == binary_sensor.DOMAIN and self._state_device_class in (
|
||||
BinarySensorDeviceClass.DOOR,
|
||||
BinarySensorDeviceClass.GARAGE_DOOR,
|
||||
BinarySensorDeviceClass.WINDOW,
|
||||
BinarySensorDeviceClass.OPENING,
|
||||
)
|
||||
|
||||
|
||||
class MotionStateEventProperty(SensorEventProperty, StateEventProperty, MotionEventProperty):
|
||||
"""Represents the state event property that detect motion, presence or occupancy."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return self.state.domain == binary_sensor.DOMAIN and self._state_device_class in (
|
||||
BinarySensorDeviceClass.MOTION,
|
||||
BinarySensorDeviceClass.OCCUPANCY,
|
||||
BinarySensorDeviceClass.PRESENCE,
|
||||
)
|
||||
|
||||
|
||||
class MotionEventPlatformProperty(EventPlatformProperty, MotionEventProperty):
|
||||
"""Represents the event platform property that detect motion."""
|
||||
|
||||
@property
|
||||
def parameters(self) -> MotionEventPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return MotionEventPropertyParameters(events=[{"value": MotionInstanceEvent.DETECTED}])
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return self.state.domain == EVENT_DOMAIN and self._state_device_class == EventDeviceClass.MOTION
|
||||
|
||||
|
||||
class GasStateEventProperty(StateEventProperty, SensorEventProperty, GasEventProperty):
|
||||
"""Represents the state event property that detect gas presence."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return self.state.domain == binary_sensor.DOMAIN and self._state_device_class == BinarySensorDeviceClass.GAS
|
||||
|
||||
|
||||
class SmokeStateEventProperty(StateEventProperty, SensorEventProperty, SmokeEventProperty):
|
||||
"""Represents the state event property that detect smoke presence."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return self.state.domain == binary_sensor.DOMAIN and self._state_device_class == BinarySensorDeviceClass.SMOKE
|
||||
|
||||
|
||||
class BatteryLevelStateEvent(StateEventProperty, SensorEventProperty, BatteryLevelEventProperty):
|
||||
"""Represents the state event property that detect low level of a battery."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return self.state.domain == binary_sensor.DOMAIN and self._state_device_class == BinarySensorDeviceClass.BATTERY
|
||||
|
||||
|
||||
class WaterLeakStateEventProperty(StateEventProperty, SensorEventProperty, WaterLeakEventProperty):
|
||||
"""Represents the state event property that detect water leakage."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return (
|
||||
self.state.domain == binary_sensor.DOMAIN and self._state_device_class == BinarySensorDeviceClass.MOISTURE
|
||||
)
|
||||
|
||||
|
||||
class ButtonPressStateEventProperty(StateEventProperty, ReactiveEventProperty, ButtonPressEventProperty):
|
||||
"""Represents the state property that detect a button interaction."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
if self.state.domain == EVENT_DOMAIN:
|
||||
return False
|
||||
|
||||
if self._state_device_class == EventDeviceClass.BUTTON:
|
||||
return True
|
||||
|
||||
if self._entry_data.get_entity_config(self.device_id).get(CONF_DEVICE_CLASS) == EventDeviceClass.BUTTON:
|
||||
return True
|
||||
|
||||
if self.state.domain == sensor.DOMAIN and self._state_device_class == XGW3DeviceClass.ACTION:
|
||||
possible_actions = self._supported_native_values
|
||||
possible_actions.extend(
|
||||
[
|
||||
"long_click_release",
|
||||
"release",
|
||||
]
|
||||
)
|
||||
|
||||
return self.state.attributes.get("action") in possible_actions
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class ButtonPressEventPlatformProperty(EventPlatformProperty, ButtonPressEventProperty):
|
||||
"""Represents the event platform property that detect a button interaction."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
if self.state.domain == EVENT_DOMAIN:
|
||||
if self._state_device_class in [EventDeviceClass.DOORBELL, EventDeviceClass.BUTTON]:
|
||||
return True
|
||||
|
||||
if self._entry_data.get_entity_config(self.device_id).get(CONF_DEVICE_CLASS) == EventDeviceClass.BUTTON:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class VibrationStateEventProperty(StateEventProperty, ReactiveEventProperty, VibrationEventProperty):
|
||||
"""Represents the state event property that detect vibration."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
if self.state.domain == binary_sensor.DOMAIN:
|
||||
if self._state_device_class == BinarySensorDeviceClass.VIBRATION:
|
||||
return True
|
||||
|
||||
if self.state.domain == sensor.DOMAIN and self._state_device_class == XGW3DeviceClass.ACTION:
|
||||
return self.state.attributes.get("action") in self._supported_native_values
|
||||
|
||||
return False
|
||||
|
||||
|
||||
STATE_PROPERTIES_REGISTRY.register(OpenStateEventProperty)
|
||||
STATE_PROPERTIES_REGISTRY.register(MotionStateEventProperty)
|
||||
STATE_PROPERTIES_REGISTRY.register(MotionEventPlatformProperty)
|
||||
STATE_PROPERTIES_REGISTRY.register(GasStateEventProperty)
|
||||
STATE_PROPERTIES_REGISTRY.register(SmokeStateEventProperty)
|
||||
STATE_PROPERTIES_REGISTRY.register(BatteryLevelStateEvent)
|
||||
STATE_PROPERTIES_REGISTRY.register(WaterLeakStateEventProperty)
|
||||
STATE_PROPERTIES_REGISTRY.register(ButtonPressStateEventProperty)
|
||||
STATE_PROPERTIES_REGISTRY.register(ButtonPressEventPlatformProperty)
|
||||
STATE_PROPERTIES_REGISTRY.register(VibrationStateEventProperty)
|
||||
@@ -0,0 +1,914 @@
|
||||
"""Implement the Yandex Smart Home float properties."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import suppress
|
||||
from functools import cached_property
|
||||
import logging
|
||||
from typing import Protocol, Self
|
||||
|
||||
from homeassistant.components import air_quality, climate, fan, humidifier, light, sensor, switch, water_heater
|
||||
from homeassistant.components.air_quality import ATTR_CO2, ATTR_PM_0_1, ATTR_PM_2_5, ATTR_PM_10
|
||||
from homeassistant.components.climate import ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_HUMIDITY
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import (
|
||||
ATTR_BATTERY_LEVEL,
|
||||
ATTR_TEMPERATURE,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
ATTR_VOLTAGE,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
PERCENTAGE,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.util.unit_conversion import (
|
||||
BaseUnitConverter,
|
||||
ElectricCurrentConverter,
|
||||
ElectricPotentialConverter,
|
||||
EnergyConverter,
|
||||
PowerConverter,
|
||||
TemperatureConverter,
|
||||
VolumeConverter,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
ATTR_CURRENT,
|
||||
ATTR_CURRENT_CONSUMPTION,
|
||||
ATTR_ILLUMINANCE,
|
||||
ATTR_LOAD_POWER,
|
||||
ATTR_POWER,
|
||||
ATTR_TVOC,
|
||||
ATTR_WATER_LEVEL,
|
||||
STATE_CHARGING,
|
||||
STATE_EMPTY,
|
||||
STATE_LOW,
|
||||
STATE_NONE,
|
||||
STATE_NONE_UI,
|
||||
XGW3DeviceClass,
|
||||
)
|
||||
from .helpers import APIError
|
||||
from .property import STATE_PROPERTIES_REGISTRY, Property, StateProperty
|
||||
from .schema import (
|
||||
AmperageFloatPropertyParameters,
|
||||
BatteryLevelFloatPropertyParameters,
|
||||
CO2LevelFloatPropertyParameters,
|
||||
ElectricityMeterFloatPropertyParameters,
|
||||
FloatPropertyDescription,
|
||||
FloatPropertyInstance,
|
||||
FloatPropertyParameters,
|
||||
FoodLevelFloatPropertyParameters,
|
||||
GasMeterFloatPropertyParameters,
|
||||
HeatMeterFloatPropertyParameters,
|
||||
HumidityFloatPropertyParameters,
|
||||
IlluminationFloatPropertyParameters,
|
||||
MeterFloatPropertyParameters,
|
||||
PM1DensityFloatPropertyParameters,
|
||||
PM10DensityFloatPropertyParameters,
|
||||
PM25DensityFloatPropertyParameters,
|
||||
PowerFloatPropertyParameters,
|
||||
PressureFloatPropertyParameters,
|
||||
PropertyType,
|
||||
ResponseCode,
|
||||
TemperatureFloatPropertyParameters,
|
||||
TVOCFloatPropertyParameters,
|
||||
VoltageFloatPropertyParameters,
|
||||
WaterLevelFloatPropertyParameters,
|
||||
WaterMeterFloatPropertyParameters,
|
||||
)
|
||||
from .unit_conversion import PressureConverter, TVOCConcentrationConverter, UnitOfPressure, UnitOfTemperature
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FloatProperty(Property, Protocol):
|
||||
"""Base class for float properties (sensors)."""
|
||||
|
||||
type: PropertyType = PropertyType.FLOAT
|
||||
instance: FloatPropertyInstance
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def parameters(self) -> FloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
...
|
||||
|
||||
def get_description(self) -> FloatPropertyDescription:
|
||||
"""Return a description for a device list request."""
|
||||
return FloatPropertyDescription(
|
||||
retrievable=self.retrievable, reportable=self.reportable, parameters=self.parameters
|
||||
)
|
||||
|
||||
def get_value(self) -> float | None:
|
||||
"""Return the current property value."""
|
||||
value = self._get_native_value()
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if str(value).lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE, STATE_NONE_UI, STATE_EMPTY):
|
||||
return None
|
||||
|
||||
try:
|
||||
float_value = float(value)
|
||||
except (ValueError, TypeError):
|
||||
raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Unsupported value '{value}' for {self}")
|
||||
|
||||
if self._native_unit_of_measurement and self.unit_of_measurement and self._unit_converter:
|
||||
if self._native_unit_of_measurement in self._unit_converter.VALID_UNITS:
|
||||
float_value = self._unit_converter.convert(
|
||||
float_value, self._native_unit_of_measurement, self.unit_of_measurement
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
f"Unsupported unit of measurement '{self._native_unit_of_measurement}' for {self}. "
|
||||
f"Valid units are: %s" % ", ".join(sorted(map(str, self._unit_converter.VALID_UNITS)))
|
||||
)
|
||||
|
||||
lower_limit, upper_limit = self.parameters.range
|
||||
if lower_limit is not None and float_value < lower_limit:
|
||||
return lower_limit
|
||||
if upper_limit is not None and float_value > upper_limit:
|
||||
return upper_limit
|
||||
|
||||
return round(float_value, 2)
|
||||
|
||||
def check_value_change(self, other: Self | None) -> bool:
|
||||
"""Test if the property value differs from other property."""
|
||||
if other is None:
|
||||
return True
|
||||
|
||||
value, other_value = self.get_value(), other.get_value()
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if other_value is None or value != other_value:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
...
|
||||
|
||||
@cached_property
|
||||
@abstractmethod
|
||||
def _native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit the native value is expressed in."""
|
||||
...
|
||||
|
||||
@property
|
||||
def _unit_converter(self) -> BaseUnitConverter | None:
|
||||
"""Return the unit converter."""
|
||||
return None # pragma: nocover
|
||||
|
||||
|
||||
class TemperatureProperty(FloatProperty, ABC):
|
||||
"""Base class for temperature properties."""
|
||||
|
||||
instance = FloatPropertyInstance.TEMPERATURE
|
||||
|
||||
@property
|
||||
def parameters(self) -> TemperatureFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return TemperatureFloatPropertyParameters(unit=self.unit_of_measurement.as_property_unit)
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> UnitOfTemperature:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
if self._native_unit_of_measurement:
|
||||
with suppress(ValueError):
|
||||
unit = UnitOfTemperature(self._native_unit_of_measurement)
|
||||
if unit.as_property_unit:
|
||||
return unit
|
||||
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def _unit_converter(self) -> TemperatureConverter:
|
||||
"""Return the unit converter."""
|
||||
return TemperatureConverter()
|
||||
|
||||
|
||||
class HumidityProperty(FloatProperty, ABC):
|
||||
"""Base class for humidity properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.HUMIDITY
|
||||
|
||||
@property
|
||||
def parameters(self) -> HumidityFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return HumidityFloatPropertyParameters()
|
||||
|
||||
|
||||
class PressureProperty(FloatProperty, ABC):
|
||||
"""Base class for pressure properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.PRESSURE
|
||||
|
||||
@property
|
||||
def parameters(self) -> PressureFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return PressureFloatPropertyParameters(unit=self.unit_of_measurement.as_property_unit)
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> UnitOfPressure:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
if self._native_unit_of_measurement:
|
||||
with suppress(ValueError):
|
||||
unit = UnitOfPressure(self._native_unit_of_measurement)
|
||||
if unit.as_property_unit:
|
||||
return unit
|
||||
|
||||
return UnitOfPressure.MMHG
|
||||
|
||||
@property
|
||||
def _unit_converter(self) -> PressureConverter:
|
||||
"""Return the unit converter."""
|
||||
return PressureConverter()
|
||||
|
||||
|
||||
class IlluminationProperty(FloatProperty, ABC):
|
||||
"""Base class for illumination properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.ILLUMINATION
|
||||
|
||||
@property
|
||||
def parameters(self) -> IlluminationFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return IlluminationFloatPropertyParameters()
|
||||
|
||||
|
||||
class FoodLevelPercentageProperty(FloatProperty, Protocol):
|
||||
"""Base class for food level (%) properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.FOOD_LEVEL
|
||||
|
||||
@property
|
||||
def parameters(self) -> FoodLevelFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return FoodLevelFloatPropertyParameters()
|
||||
|
||||
|
||||
class WaterLevelPercentageProperty(FloatProperty, Protocol):
|
||||
"""Base class for water level (%) properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.WATER_LEVEL
|
||||
|
||||
@property
|
||||
def parameters(self) -> WaterLevelFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return WaterLevelFloatPropertyParameters()
|
||||
|
||||
|
||||
class CO2LevelProperty(FloatProperty, Protocol):
|
||||
"""Base class for CO2 level properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.CO2_LEVEL
|
||||
|
||||
@property
|
||||
def parameters(self) -> CO2LevelFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return CO2LevelFloatPropertyParameters()
|
||||
|
||||
|
||||
class MeterProperty(FloatProperty, Protocol):
|
||||
"""Base class for meter properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.METER
|
||||
|
||||
@property
|
||||
def parameters(self) -> MeterFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return MeterFloatPropertyParameters()
|
||||
|
||||
|
||||
class ElectricityMeterProperty(FloatProperty, Protocol):
|
||||
"""Base class for electricity meter properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.ELECTRICITY_METER
|
||||
|
||||
@property
|
||||
def parameters(self) -> ElectricityMeterFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return ElectricityMeterFloatPropertyParameters()
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> UnitOfEnergy:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
return UnitOfEnergy.KILO_WATT_HOUR
|
||||
|
||||
@property
|
||||
def _unit_converter(self) -> EnergyConverter:
|
||||
"""Return the unit converter."""
|
||||
return EnergyConverter()
|
||||
|
||||
|
||||
class GasMeterProperty(FloatProperty, Protocol):
|
||||
"""Base class for gas meter properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.GAS_METER
|
||||
|
||||
@property
|
||||
def parameters(self) -> GasMeterFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return GasMeterFloatPropertyParameters()
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> UnitOfVolume:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
return UnitOfVolume.CUBIC_METERS
|
||||
|
||||
@property
|
||||
def _unit_converter(self) -> VolumeConverter:
|
||||
"""Return the unit converter."""
|
||||
return VolumeConverter()
|
||||
|
||||
|
||||
class HeatMeterProperty(FloatProperty, Protocol):
|
||||
"""Base class for heat meter properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.HEAT_METER
|
||||
|
||||
@property
|
||||
def parameters(self) -> HeatMeterFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return HeatMeterFloatPropertyParameters()
|
||||
|
||||
|
||||
class WaterMeterProperty(FloatProperty, Protocol):
|
||||
"""Base class for water meter properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.WATER_METER
|
||||
|
||||
@property
|
||||
def parameters(self) -> WaterMeterFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return WaterMeterFloatPropertyParameters()
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> UnitOfVolume:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
return UnitOfVolume.CUBIC_METERS
|
||||
|
||||
@property
|
||||
def _unit_converter(self) -> VolumeConverter:
|
||||
"""Return the unit converter."""
|
||||
return VolumeConverter()
|
||||
|
||||
|
||||
class PM1DensityProperty(FloatProperty, Protocol):
|
||||
"""Base class for PM1 density properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.PM1_DENSITY
|
||||
|
||||
@property
|
||||
def parameters(self) -> PM1DensityFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return PM1DensityFloatPropertyParameters()
|
||||
|
||||
|
||||
class PM25DensityProperty(FloatProperty, Protocol):
|
||||
"""Base class for PM2.5 density properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.PM2_5_DENSITY
|
||||
|
||||
@property
|
||||
def parameters(self) -> PM25DensityFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return PM25DensityFloatPropertyParameters()
|
||||
|
||||
|
||||
class PM10DensityProperty(FloatProperty, Protocol):
|
||||
"""Base class for PM10 density properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.PM10_DENSITY
|
||||
|
||||
@property
|
||||
def parameters(self) -> PM10DensityFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return PM10DensityFloatPropertyParameters()
|
||||
|
||||
|
||||
class TVOCConcentrationProperty(FloatProperty, Protocol):
|
||||
"""Base class for TVOC concentration properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.TVOC
|
||||
|
||||
@property
|
||||
def parameters(self) -> TVOCFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return TVOCFloatPropertyParameters()
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> str:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
|
||||
@property
|
||||
def _unit_converter(self) -> TVOCConcentrationConverter:
|
||||
"""Return the unit converter."""
|
||||
return TVOCConcentrationConverter()
|
||||
|
||||
|
||||
class VoltageProperty(FloatProperty, Protocol):
|
||||
"""Base class for voltage properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.VOLTAGE
|
||||
|
||||
@property
|
||||
def parameters(self) -> VoltageFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return VoltageFloatPropertyParameters()
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> str:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
return UnitOfElectricPotential.VOLT
|
||||
|
||||
@property
|
||||
def _unit_converter(self) -> ElectricPotentialConverter:
|
||||
"""Return the unit converter."""
|
||||
return ElectricPotentialConverter()
|
||||
|
||||
|
||||
class ElectricCurrentProperty(FloatProperty, Protocol):
|
||||
"""Base class for electric current properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.AMPERAGE
|
||||
|
||||
@property
|
||||
def parameters(self) -> AmperageFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return AmperageFloatPropertyParameters()
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> str:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
return UnitOfElectricCurrent.AMPERE
|
||||
|
||||
@property
|
||||
def _unit_converter(self) -> ElectricCurrentConverter:
|
||||
"""Return the unit converter."""
|
||||
return ElectricCurrentConverter()
|
||||
|
||||
|
||||
class ElectricPowerProperty(FloatProperty, Protocol):
|
||||
"""Base class for electric power properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.POWER
|
||||
|
||||
@property
|
||||
def parameters(self) -> PowerFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return PowerFloatPropertyParameters()
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self) -> str:
|
||||
"""Return the unit the property value is expressed in."""
|
||||
return UnitOfPower.WATT
|
||||
|
||||
@property
|
||||
def _unit_converter(self) -> PowerConverter:
|
||||
"""Return the unit converter."""
|
||||
return PowerConverter()
|
||||
|
||||
|
||||
class BatteryLevelPercentageProperty(FloatProperty, Protocol):
|
||||
"""Base class for battery level (%) properties."""
|
||||
|
||||
instance: FloatPropertyInstance = FloatPropertyInstance.BATTERY_LEVEL
|
||||
|
||||
@property
|
||||
def parameters(self) -> BatteryLevelFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return BatteryLevelFloatPropertyParameters()
|
||||
|
||||
|
||||
class StateFloatProperty(StateProperty, FloatProperty):
|
||||
"""Base class for a float property based on the state."""
|
||||
|
||||
@cached_property
|
||||
def _native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit the native value is expressed in."""
|
||||
if self.state.domain == sensor.DOMAIN:
|
||||
return self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class TemperatureSensor(StateFloatProperty, TemperatureProperty):
|
||||
"""Representaton of the state as a temperature sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
match self.state.domain:
|
||||
case sensor.DOMAIN:
|
||||
if self._state_device_class == SensorDeviceClass.TEMPERATURE:
|
||||
return True
|
||||
if self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) in UnitOfTemperature.__members__.values():
|
||||
return True
|
||||
case air_quality.DOMAIN:
|
||||
return self.state.attributes.get(ATTR_TEMPERATURE) is not None
|
||||
case climate.DOMAIN | fan.DOMAIN | humidifier.DOMAIN | water_heater.DOMAIN:
|
||||
return self.state.attributes.get(ATTR_CURRENT_TEMPERATURE) is not None
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
match self.state.domain:
|
||||
case air_quality.DOMAIN:
|
||||
return self.state.attributes.get(ATTR_TEMPERATURE)
|
||||
case climate.DOMAIN | fan.DOMAIN | humidifier.DOMAIN | water_heater.DOMAIN:
|
||||
return self.state.attributes.get(ATTR_CURRENT_TEMPERATURE)
|
||||
|
||||
return self.state.state
|
||||
|
||||
|
||||
class HumiditySensor(StateFloatProperty, HumidityProperty):
|
||||
"""Representaton of the state as a humidity sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
match self.state.domain:
|
||||
case sensor.DOMAIN:
|
||||
return self._state_device_class in (SensorDeviceClass.HUMIDITY, SensorDeviceClass.MOISTURE)
|
||||
case air_quality.DOMAIN:
|
||||
return self.state.attributes.get(ATTR_HUMIDITY) is not None
|
||||
case climate.DOMAIN | fan.DOMAIN | humidifier.DOMAIN:
|
||||
return self.state.attributes.get(ATTR_CURRENT_HUMIDITY) is not None
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
match self.state.domain:
|
||||
case air_quality.DOMAIN:
|
||||
return self.state.attributes.get(ATTR_HUMIDITY)
|
||||
case climate.DOMAIN | fan.DOMAIN | humidifier.DOMAIN:
|
||||
return self.state.attributes.get(ATTR_CURRENT_HUMIDITY)
|
||||
|
||||
return self.state.state
|
||||
|
||||
|
||||
class PressureSensor(StateFloatProperty, PressureProperty):
|
||||
"""Representaton of the state as a pressure sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return self.state.domain == sensor.DOMAIN and self._state_device_class in (
|
||||
SensorDeviceClass.PRESSURE,
|
||||
SensorDeviceClass.ATMOSPHERIC_PRESSURE,
|
||||
)
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
return self.state.state
|
||||
|
||||
|
||||
class IlluminationSensor(StateFloatProperty, IlluminationProperty):
|
||||
"""Representaton of the state as a illumination sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
if self.state.domain == sensor.DOMAIN and self._state_device_class == SensorDeviceClass.ILLUMINANCE:
|
||||
return True
|
||||
|
||||
if self.state.domain in (sensor.DOMAIN, light.DOMAIN, fan.DOMAIN):
|
||||
return ATTR_ILLUMINANCE in self.state.attributes
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
if self.state.domain == sensor.DOMAIN and self._state_device_class == SensorDeviceClass.ILLUMINANCE:
|
||||
return self.state.state
|
||||
|
||||
return self.state.attributes.get(ATTR_ILLUMINANCE)
|
||||
|
||||
|
||||
class WaterLevelPercentageSensor(StateFloatProperty, WaterLevelPercentageProperty):
|
||||
"""Representaton of the state as a water level sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
if self.state.domain in (fan.DOMAIN, humidifier.DOMAIN):
|
||||
return ATTR_WATER_LEVEL in self.state.attributes
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
return self.state.attributes.get(ATTR_WATER_LEVEL)
|
||||
|
||||
|
||||
class CO2LevelSensor(StateFloatProperty, CO2LevelProperty):
|
||||
"""Representaton of the state as a CO2 level sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
match self.state.domain:
|
||||
case sensor.DOMAIN:
|
||||
return self._state_device_class == SensorDeviceClass.CO2
|
||||
case air_quality.DOMAIN | fan.DOMAIN:
|
||||
return ATTR_CO2 in self.state.attributes
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
if self.state.domain == sensor.DOMAIN:
|
||||
return self.state.state
|
||||
|
||||
return self.state.attributes.get(ATTR_CO2)
|
||||
|
||||
|
||||
class ElectricityMeterSensor(StateFloatProperty, ElectricityMeterProperty):
|
||||
"""Representaton of the state as a electricity meter sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return self.state.domain == sensor.DOMAIN and self._state_device_class == SensorDeviceClass.ENERGY
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
return self.state.state
|
||||
|
||||
|
||||
class GasMeterSensor(StateFloatProperty, GasMeterProperty):
|
||||
"""Representaton of the state as a gas meter sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return self.state.domain == sensor.DOMAIN and self._state_device_class == SensorDeviceClass.GAS
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
return self.state.state
|
||||
|
||||
|
||||
class WaterMeterSensor(StateFloatProperty, WaterMeterProperty):
|
||||
"""Representaton of the state as a water meter sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return self.state.domain == sensor.DOMAIN and self._state_device_class == SensorDeviceClass.WATER
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
return self.state.state
|
||||
|
||||
|
||||
class PM1DensitySensor(StateFloatProperty, PM1DensityProperty):
|
||||
"""Representaton of the state as a PM1 density sensor."""
|
||||
|
||||
instance = FloatPropertyInstance.PM1_DENSITY
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
match self.state.domain:
|
||||
case sensor.DOMAIN:
|
||||
return self._state_device_class == SensorDeviceClass.PM1
|
||||
case air_quality.DOMAIN:
|
||||
return ATTR_PM_0_1 in self.state.attributes
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def parameters(self) -> PM1DensityFloatPropertyParameters:
|
||||
"""Return parameters for a devices list request."""
|
||||
return PM1DensityFloatPropertyParameters()
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
if self.state.domain == sensor.DOMAIN:
|
||||
return self.state.state
|
||||
|
||||
return self.state.attributes.get(ATTR_PM_0_1)
|
||||
|
||||
|
||||
class PM25DensitySensor(StateFloatProperty, PM25DensityProperty):
|
||||
"""Representaton of the state as a PM2.5 density sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
match self.state.domain:
|
||||
case sensor.DOMAIN:
|
||||
return self._state_device_class == SensorDeviceClass.PM25
|
||||
case air_quality.DOMAIN:
|
||||
return ATTR_PM_2_5 in self.state.attributes
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
if self.state.domain == sensor.DOMAIN:
|
||||
return self.state.state
|
||||
|
||||
return self.state.attributes.get(ATTR_PM_2_5)
|
||||
|
||||
|
||||
class PM10DensitySensor(StateFloatProperty, PM10DensityProperty):
|
||||
"""Representaton of the state as a PM10 density sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
match self.state.domain:
|
||||
case sensor.DOMAIN:
|
||||
return self._state_device_class == SensorDeviceClass.PM10
|
||||
case air_quality.DOMAIN:
|
||||
return ATTR_PM_10 in self.state.attributes
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
if self.state.domain == sensor.DOMAIN:
|
||||
return self.state.state
|
||||
|
||||
return self.state.attributes.get(ATTR_PM_10)
|
||||
|
||||
|
||||
class TVOCConcentrationSensor(StateFloatProperty, TVOCConcentrationProperty):
|
||||
"""Representaton of the state as a TVOC concentration sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
match self.state.domain:
|
||||
case sensor.DOMAIN:
|
||||
return self._state_device_class in (
|
||||
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
XGW3DeviceClass.TVOC,
|
||||
)
|
||||
case air_quality.DOMAIN:
|
||||
return ATTR_TVOC in self.state.attributes
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
if self.state.domain == sensor.DOMAIN:
|
||||
return self.state.state
|
||||
|
||||
return self.state.attributes.get(ATTR_TVOC)
|
||||
|
||||
|
||||
class VOCConcentrationSensor(StateFloatProperty, TVOCConcentrationProperty):
|
||||
"""Representaton of the state as a VOC concentration sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
return (
|
||||
self.state.domain == sensor.DOMAIN
|
||||
and self._state_device_class == SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
return self.state.state
|
||||
|
||||
@property
|
||||
def _native_unit_of_measurement(self) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
class VoltageSensor(StateFloatProperty, VoltageProperty):
|
||||
"""Representaton of the state as a voltage sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
match self.state.domain:
|
||||
case sensor.DOMAIN:
|
||||
return self._state_device_class == SensorDeviceClass.VOLTAGE
|
||||
case switch.DOMAIN | light.DOMAIN:
|
||||
return ATTR_VOLTAGE in self.state.attributes
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
if self.state.domain == sensor.DOMAIN:
|
||||
return self.state.state
|
||||
|
||||
return self.state.attributes.get(ATTR_VOLTAGE)
|
||||
|
||||
|
||||
class ElectricCurrentSensor(StateFloatProperty, ElectricCurrentProperty):
|
||||
"""Representaton of the state as a electric current sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
match self.state.domain:
|
||||
case sensor.DOMAIN:
|
||||
return self._state_device_class == SensorDeviceClass.CURRENT
|
||||
case switch.DOMAIN | light.DOMAIN:
|
||||
return ATTR_CURRENT in self.state.attributes
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
if self.state.domain == sensor.DOMAIN:
|
||||
return self.state.state
|
||||
|
||||
return self.state.attributes.get(ATTR_CURRENT)
|
||||
|
||||
|
||||
class ElectricPowerSensor(StateFloatProperty, ElectricPowerProperty):
|
||||
"""Representaton of the state as a electric power sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
if self.state.domain == sensor.DOMAIN:
|
||||
return self._state_device_class == SensorDeviceClass.POWER
|
||||
|
||||
if self.state.domain == switch.DOMAIN:
|
||||
for attribute in (ATTR_POWER, ATTR_LOAD_POWER, ATTR_CURRENT_CONSUMPTION):
|
||||
if attribute in self.state.attributes:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
if self.state.domain == switch.DOMAIN:
|
||||
for attribute in (ATTR_POWER, ATTR_LOAD_POWER, ATTR_CURRENT_CONSUMPTION):
|
||||
if attribute in self.state.attributes:
|
||||
return self.state.attributes.get(attribute)
|
||||
|
||||
return self.state.state
|
||||
|
||||
|
||||
class BatteryLevelPercentageSensor(StateFloatProperty, BatteryLevelPercentageProperty):
|
||||
"""Representaton of the state as battery level sensor."""
|
||||
|
||||
@property
|
||||
def supported(self) -> bool:
|
||||
"""Test if the property is supported."""
|
||||
if (
|
||||
self._state_device_class == SensorDeviceClass.BATTERY
|
||||
and self.state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
|
||||
):
|
||||
return True
|
||||
|
||||
return ATTR_BATTERY_LEVEL in self.state.attributes
|
||||
|
||||
def _get_native_value(self) -> float | str | None:
|
||||
"""Return the current property value without conversion."""
|
||||
value = None
|
||||
if self._state_device_class == SensorDeviceClass.BATTERY:
|
||||
value = self.state.state
|
||||
elif ATTR_BATTERY_LEVEL in self.state.attributes:
|
||||
value = self.state.attributes.get(ATTR_BATTERY_LEVEL)
|
||||
|
||||
if value in [STATE_LOW, STATE_CHARGING]:
|
||||
return 0
|
||||
|
||||
return value
|
||||
|
||||
|
||||
STATE_PROPERTIES_REGISTRY.register(TemperatureSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(HumiditySensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(PressureSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(IlluminationSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(WaterLevelPercentageSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(CO2LevelSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(ElectricityMeterSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(GasMeterSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(WaterMeterSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(PM1DensitySensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(PM25DensitySensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(PM10DensitySensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(TVOCConcentrationSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(VOCConcentrationSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(VoltageSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(ElectricCurrentSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(ElectricPowerSensor)
|
||||
STATE_PROPERTIES_REGISTRY.register(BatteryLevelPercentageSensor)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Repairs for the Yandex Smart Home."""
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir, label_registry as lr
|
||||
from homeassistant.helpers.entityfilter import CONF_INCLUDE_ENTITIES
|
||||
from homeassistant.helpers.selector import BooleanSelector
|
||||
import voluptuous as vol
|
||||
|
||||
from . import DOMAIN
|
||||
from .const import CONF_ADD_LABEL, CONF_FILTER, CONF_LABEL, ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND, EntityFilterSource
|
||||
from .entry_data import ConfigEntryData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import YandexSmartHome
|
||||
|
||||
|
||||
class EmptyRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow without any side effects."""
|
||||
|
||||
async def async_step_init(self, _: dict[str, str] | None = None) -> FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
|
||||
class UnexposedEntityFoundConfigEntryRepairFlow(RepairsFlow):
|
||||
"""Handler for an "unexposed entity found" issue fixing flow."""
|
||||
|
||||
def __init__(self, entry_data: ConfigEntryData) -> None:
|
||||
"""Initialize the flow."""
|
||||
self._entry_data = entry_data
|
||||
|
||||
async def async_step_init(self, _: dict[str, str] | None = None) -> FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(self, user_input: dict[str, str] | None = None) -> FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
if user_input[CONF_INCLUDE_ENTITIES]:
|
||||
entry = self._entry_data.entry
|
||||
options = entry.options.copy()
|
||||
options[CONF_FILTER] = {
|
||||
CONF_INCLUDE_ENTITIES: sorted(
|
||||
set(entry.options[CONF_FILTER][CONF_INCLUDE_ENTITIES]) | self._entry_data.unexposed_entities
|
||||
)
|
||||
}
|
||||
self.hass.config_entries.async_update_entry(entry, options=options)
|
||||
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_INCLUDE_ENTITIES): BooleanSelector()}),
|
||||
)
|
||||
|
||||
|
||||
class UnexposedEntityFoundLabelRepairFlow(RepairsFlow):
|
||||
"""Handler for an "unexposed entity found" issue fixing flow."""
|
||||
|
||||
def __init__(self, entry_data: ConfigEntryData) -> None:
|
||||
"""Initialize the flow."""
|
||||
self._entry_data = entry_data
|
||||
|
||||
async def async_step_init(self, _: dict[str, str] | None = None) -> FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(self, user_input: dict[str, str] | None = None) -> FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
label = self._entry_data.entry.options[CONF_LABEL]
|
||||
label_entry = lr.async_get(self.hass).async_get_label(label)
|
||||
|
||||
if user_input is not None:
|
||||
if user_input[CONF_ADD_LABEL]:
|
||||
registry = er.async_get(self.hass)
|
||||
for entity_id in self._entry_data.unexposed_entities:
|
||||
if entity := registry.async_get(entity_id):
|
||||
registry.async_update_entity(
|
||||
entity.entity_id,
|
||||
labels=entity.labels | {label},
|
||||
)
|
||||
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_ADD_LABEL): BooleanSelector()}),
|
||||
description_placeholders={CONF_LABEL: label_entry.name if label_entry else label},
|
||||
)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant, issue_id: str, data: dict[str, str | int | float | None] | None
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
assert data is not None
|
||||
entry = hass.config_entries.async_get_entry(cast(str, data["entry_id"]))
|
||||
if not entry or DOMAIN not in hass.data:
|
||||
return EmptyRepairFlow()
|
||||
|
||||
component: YandexSmartHome = hass.data[DOMAIN]
|
||||
try:
|
||||
entry_data = component.get_entry_data(entry)
|
||||
except KeyError:
|
||||
return EmptyRepairFlow()
|
||||
|
||||
if issue_id == ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + EntityFilterSource.CONFIG_ENTRY:
|
||||
return UnexposedEntityFoundConfigEntryRepairFlow(entry_data)
|
||||
|
||||
if issue_id == ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + EntityFilterSource.LABEL:
|
||||
return UnexposedEntityFoundLabelRepairFlow(entry_data)
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def delete_unexposed_entity_found_issues(hass: HomeAssistant) -> None:
|
||||
"""Delete repair issues for an unexposed entity."""
|
||||
for filter_source in EntityFilterSource:
|
||||
ir.async_delete_issue(hass, DOMAIN, ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + filter_source)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Yandex Smart Home API schemas."""
|
||||
|
||||
# ruff: noqa
|
||||
from .callback import *
|
||||
from .capability import *
|
||||
from .capability_color import *
|
||||
from .capability_mode import *
|
||||
from .capability_onoff import *
|
||||
from .capability_range import *
|
||||
from .capability_toggle import *
|
||||
from .capability_video import *
|
||||
from .device import *
|
||||
from .property import *
|
||||
from .property_event import *
|
||||
from .property_float import *
|
||||
from .response import *
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Base class for API response schemas."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
from pydantic.v1.generics import GenericModel
|
||||
|
||||
|
||||
class APIModel(BaseModel):
|
||||
"""Base API response model."""
|
||||
|
||||
def as_json(self) -> str:
|
||||
"""Generate a JSON representation of the model."""
|
||||
return super().json(exclude_none=True, ensure_ascii=False)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Generate a dictionary representation of the model."""
|
||||
return super().dict(exclude_none=True)
|
||||
|
||||
|
||||
class GenericAPIModel(GenericModel, APIModel):
|
||||
"""Base generic API response model."""
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Schema for event notification service.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/reference-alerts/resources-alerts.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
import time
|
||||
|
||||
from pydantic.v1 import Field
|
||||
|
||||
from .base import APIModel
|
||||
from .device import DeviceState
|
||||
|
||||
|
||||
class CallbackStatesRequestPayload(APIModel):
|
||||
"""Payload of request body for notification about device state change."""
|
||||
|
||||
user_id: str
|
||||
devices: list[DeviceState]
|
||||
|
||||
|
||||
class CallbackStatesRequest(APIModel):
|
||||
"""Request body for notification about device state change."""
|
||||
|
||||
ts: float = Field(default_factory=lambda: time.time())
|
||||
payload: CallbackStatesRequestPayload
|
||||
|
||||
|
||||
class CallbackDiscoveryRequestPayload(APIModel):
|
||||
"""Payload of request body for notification about change of devices' parameters."""
|
||||
|
||||
user_id: str
|
||||
|
||||
|
||||
class CallbackDiscoveryRequest(APIModel):
|
||||
"""Request body for notification about change of devices' parameters."""
|
||||
|
||||
ts: float = Field(default_factory=lambda: time.time())
|
||||
payload: CallbackDiscoveryRequestPayload
|
||||
|
||||
|
||||
class CallbackResponseStatus(StrEnum):
|
||||
"""Status of a callback request."""
|
||||
|
||||
OK = "ok"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class CallbackResponse(APIModel):
|
||||
"""Response on a callback request."""
|
||||
|
||||
status: CallbackResponseStatus
|
||||
error_code: str | None
|
||||
error_message: str | None
|
||||
|
||||
|
||||
CallbackRequest = CallbackDiscoveryRequest | CallbackStatesRequest
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Schema for device capabilities."""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Any, Literal, TypeVar, Union
|
||||
|
||||
from pydantic.v1 import Field
|
||||
|
||||
from .base import APIModel
|
||||
from .capability_color import (
|
||||
ColorSettingCapabilityInstance,
|
||||
ColorSettingCapabilityInstanceActionState,
|
||||
ColorSettingCapabilityParameters,
|
||||
RGBInstanceActionState,
|
||||
SceneInstanceActionState,
|
||||
TemperatureKInstanceActionState,
|
||||
)
|
||||
from .capability_mode import ModeCapabilityInstance, ModeCapabilityInstanceActionState, ModeCapabilityParameters
|
||||
from .capability_onoff import OnOffCapabilityInstance, OnOffCapabilityInstanceActionState, OnOffCapabilityParameters
|
||||
from .capability_range import RangeCapabilityInstance, RangeCapabilityInstanceActionState, RangeCapabilityParameters
|
||||
from .capability_toggle import ToggleCapabilityInstance, ToggleCapabilityInstanceActionState, ToggleCapabilityParameters
|
||||
from .capability_video import (
|
||||
GetStreamInstanceActionResultValue,
|
||||
GetStreamInstanceActionState,
|
||||
VideoStreamCapabilityInstance,
|
||||
VideoStreamCapabilityParameters,
|
||||
)
|
||||
|
||||
|
||||
class CapabilityType(StrEnum):
|
||||
"""Capability type."""
|
||||
|
||||
ON_OFF = "devices.capabilities.on_off"
|
||||
COLOR_SETTING = "devices.capabilities.color_setting"
|
||||
MODE = "devices.capabilities.mode"
|
||||
RANGE = "devices.capabilities.range"
|
||||
TOGGLE = "devices.capabilities.toggle"
|
||||
VIDEO_STREAM = "devices.capabilities.video_stream"
|
||||
|
||||
@property
|
||||
def short(self) -> str:
|
||||
"""Return short version of the capability type."""
|
||||
return str(self).replace("devices.capabilities.", "")
|
||||
|
||||
|
||||
CapabilityParameters = (
|
||||
OnOffCapabilityParameters
|
||||
| ColorSettingCapabilityParameters
|
||||
| ModeCapabilityParameters
|
||||
| RangeCapabilityParameters
|
||||
| ToggleCapabilityParameters
|
||||
| VideoStreamCapabilityParameters
|
||||
)
|
||||
"""Parameters of a capability for a device list request."""
|
||||
|
||||
CapabilityInstance = (
|
||||
OnOffCapabilityInstance
|
||||
| ColorSettingCapabilityInstance
|
||||
| ModeCapabilityInstance
|
||||
| RangeCapabilityInstance
|
||||
| ToggleCapabilityInstance
|
||||
| VideoStreamCapabilityInstance
|
||||
)
|
||||
"""All capability instances."""
|
||||
|
||||
|
||||
class CapabilityDescription(APIModel):
|
||||
"""Description of a capability for a device list request."""
|
||||
|
||||
type: CapabilityType
|
||||
retrievable: bool
|
||||
reportable: bool
|
||||
parameters: CapabilityParameters | None
|
||||
|
||||
|
||||
class CapabilityInstanceStateValue(APIModel):
|
||||
"""Capability instance value."""
|
||||
|
||||
instance: CapabilityInstance
|
||||
value: Any
|
||||
|
||||
|
||||
class CapabilityInstanceState(APIModel):
|
||||
"""Capability state for state query and callback requests."""
|
||||
|
||||
type: CapabilityType
|
||||
state: CapabilityInstanceStateValue
|
||||
|
||||
|
||||
class OnOffCapabilityInstanceAction(APIModel):
|
||||
"""New capability state for a state change request of on_off capability."""
|
||||
|
||||
type: Literal[CapabilityType.ON_OFF] = CapabilityType.ON_OFF
|
||||
state: OnOffCapabilityInstanceActionState
|
||||
|
||||
|
||||
class ColorSettingCapabilityInstanceAction(APIModel):
|
||||
"""New capability state for a state change request of color_setting capability."""
|
||||
|
||||
type: Literal[CapabilityType.COLOR_SETTING] = CapabilityType.COLOR_SETTING
|
||||
state: ColorSettingCapabilityInstanceActionState
|
||||
|
||||
|
||||
class ModeCapabilityInstanceAction(APIModel):
|
||||
"""New capability state for a state change request of mode capability."""
|
||||
|
||||
type: Literal[CapabilityType.MODE] = CapabilityType.MODE
|
||||
state: ModeCapabilityInstanceActionState
|
||||
|
||||
|
||||
class RangeCapabilityInstanceAction(APIModel):
|
||||
"""New capability state for a state change request of range capability."""
|
||||
|
||||
type: Literal[CapabilityType.RANGE] = CapabilityType.RANGE
|
||||
state: RangeCapabilityInstanceActionState
|
||||
|
||||
|
||||
class ToggleCapabilityInstanceAction(APIModel):
|
||||
"""New capability state for a state change request of toggle capability."""
|
||||
|
||||
type: Literal[CapabilityType.TOGGLE] = CapabilityType.TOGGLE
|
||||
state: ToggleCapabilityInstanceActionState
|
||||
|
||||
|
||||
class VideoStreamCapabilityInstanceAction(APIModel):
|
||||
"""New capability state for a state change request of video_stream capability."""
|
||||
|
||||
type: Literal[CapabilityType.VIDEO_STREAM] = CapabilityType.VIDEO_STREAM
|
||||
state: GetStreamInstanceActionState
|
||||
|
||||
|
||||
CapabilityInstanceAction = Annotated[
|
||||
Union[
|
||||
OnOffCapabilityInstanceAction,
|
||||
ColorSettingCapabilityInstanceAction,
|
||||
ModeCapabilityInstanceAction,
|
||||
RangeCapabilityInstanceAction,
|
||||
ToggleCapabilityInstanceAction,
|
||||
VideoStreamCapabilityInstanceAction,
|
||||
],
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
"""New capability state including type for a state change request."""
|
||||
|
||||
CapabilityInstanceActionState = TypeVar(
|
||||
"CapabilityInstanceActionState",
|
||||
OnOffCapabilityInstanceActionState,
|
||||
ColorSettingCapabilityInstanceActionState,
|
||||
RGBInstanceActionState,
|
||||
TemperatureKInstanceActionState,
|
||||
SceneInstanceActionState,
|
||||
ModeCapabilityInstanceActionState,
|
||||
RangeCapabilityInstanceActionState,
|
||||
ToggleCapabilityInstanceActionState,
|
||||
GetStreamInstanceActionState,
|
||||
contravariant=True,
|
||||
)
|
||||
"""New capability state for a state change request."""
|
||||
|
||||
CapabilityInstanceActionResultValue = GetStreamInstanceActionResultValue | None
|
||||
"""Result of a capability state change."""
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Schema for color_setting capability.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/color_setting.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Any, Literal, Self, Union
|
||||
|
||||
from pydantic.v1 import Field, root_validator
|
||||
|
||||
from .base import APIModel
|
||||
|
||||
|
||||
class ColorSettingCapabilityInstance(StrEnum):
|
||||
"""Instance of a color_setting capability."""
|
||||
|
||||
BASE = "base"
|
||||
RGB = "rgb"
|
||||
HSV = "hsv"
|
||||
TEMPERATURE_K = "temperature_k"
|
||||
SCENE = "scene"
|
||||
|
||||
|
||||
class ColorScene(StrEnum):
|
||||
"""Color scene."""
|
||||
|
||||
ALARM = "alarm"
|
||||
ALICE = "alice"
|
||||
CANDLE = "candle"
|
||||
DINNER = "dinner"
|
||||
FANTASY = "fantasy"
|
||||
GARLAND = "garland"
|
||||
JUNGLE = "jungle"
|
||||
MOVIE = "movie"
|
||||
NEON = "neon"
|
||||
NIGHT = "night"
|
||||
OCEAN = "ocean"
|
||||
PARTY = "party"
|
||||
READING = "reading"
|
||||
REST = "rest"
|
||||
ROMANCE = "romance"
|
||||
SIREN = "siren"
|
||||
SUNRISE = "sunrise"
|
||||
SUNSET = "sunset"
|
||||
|
||||
|
||||
class CapabilityParameterColorModel(StrEnum):
|
||||
"""Color model."""
|
||||
|
||||
RGB = "rgb"
|
||||
HSV = "hsv"
|
||||
|
||||
|
||||
class CapabilityParameterTemperatureK(APIModel):
|
||||
"""Color temperature range."""
|
||||
|
||||
min: int
|
||||
max: int
|
||||
|
||||
|
||||
class CapabilityParameterColorScene(APIModel):
|
||||
"""Parameter of a scene instance."""
|
||||
|
||||
scenes: list[dict[Literal["id"], ColorScene]]
|
||||
|
||||
@classmethod
|
||||
def from_list(cls, scenes: list[ColorScene]) -> Self:
|
||||
return cls(scenes=[{"id": s} for s in scenes])
|
||||
|
||||
|
||||
class ColorSettingCapabilityParameters(APIModel):
|
||||
"""Parameters of a color_setting capability."""
|
||||
|
||||
color_model: CapabilityParameterColorModel | None = None
|
||||
temperature_k: CapabilityParameterTemperatureK | None = None
|
||||
color_scene: CapabilityParameterColorScene | None = None
|
||||
|
||||
@root_validator
|
||||
def any_of(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
if not any(values.values()):
|
||||
raise ValueError("one of color_model, temperature_k or color_scene must have a value")
|
||||
|
||||
return values
|
||||
|
||||
|
||||
class RGBInstanceActionState(APIModel):
|
||||
"""New value for a rgb instance."""
|
||||
|
||||
instance: Literal[ColorSettingCapabilityInstance.RGB] = ColorSettingCapabilityInstance.RGB
|
||||
value: int
|
||||
|
||||
|
||||
class TemperatureKInstanceActionState(APIModel):
|
||||
"""New value for a temperature_k instance."""
|
||||
|
||||
instance: Literal[ColorSettingCapabilityInstance.TEMPERATURE_K] = ColorSettingCapabilityInstance.TEMPERATURE_K
|
||||
value: int
|
||||
|
||||
|
||||
class SceneInstanceActionState(APIModel):
|
||||
"""New value for a scene instance."""
|
||||
|
||||
instance: Literal[ColorSettingCapabilityInstance.SCENE] = ColorSettingCapabilityInstance.SCENE
|
||||
value: ColorScene
|
||||
|
||||
|
||||
ColorSettingCapabilityInstanceActionState = Annotated[
|
||||
Union[RGBInstanceActionState, TemperatureKInstanceActionState, SceneInstanceActionState],
|
||||
Field(discriminator="instance"),
|
||||
]
|
||||
"""New value for an instance of color_setting capability."""
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Schema for mode capability.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal, Self
|
||||
|
||||
from .base import APIModel
|
||||
|
||||
|
||||
class ModeCapabilityInstance(StrEnum):
|
||||
"""Instance of a mode capability.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode-instance.html
|
||||
"""
|
||||
|
||||
CLEANUP_MODE = "cleanup_mode"
|
||||
COFFEE_MODE = "coffee_mode"
|
||||
DISHWASHING = "dishwashing"
|
||||
FAN_SPEED = "fan_speed"
|
||||
HEAT = "heat"
|
||||
INPUT_SOURCE = "input_source"
|
||||
PROGRAM = "program"
|
||||
SWING = "swing"
|
||||
TEA_MODE = "tea_mode"
|
||||
THERMOSTAT = "thermostat"
|
||||
VENTILATION_MODE = "ventilation_mode"
|
||||
WORK_SPEED = "work_speed"
|
||||
|
||||
|
||||
class ModeCapabilityMode(StrEnum):
|
||||
"""Mode value of a mode capability.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/mode-instance-modes.html
|
||||
"""
|
||||
|
||||
WET_CLEANING = "wet_cleaning"
|
||||
DRY_CLEANING = "dry_cleaning"
|
||||
MIXED_CLEANING = "mixed_cleaning"
|
||||
AUTO = "auto"
|
||||
ECO = "eco"
|
||||
SMART = "smart"
|
||||
TURBO = "turbo"
|
||||
COOL = "cool"
|
||||
DRY = "dry"
|
||||
FAN_ONLY = "fan_only"
|
||||
HEAT = "heat"
|
||||
PREHEAT = "preheat"
|
||||
HIGH = "high"
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
MAX = "max"
|
||||
MIN = "min"
|
||||
FAST = "fast"
|
||||
SLOW = "slow"
|
||||
EXPRESS = "express"
|
||||
NORMAL = "normal"
|
||||
QUIET = "quiet"
|
||||
HORIZONTAL = "horizontal"
|
||||
STATIONARY = "stationary"
|
||||
VERTICAL = "vertical"
|
||||
SUPPLY_AIR = "supply_air"
|
||||
EXTRACTION_AIR = "extraction_air"
|
||||
ONE = "one"
|
||||
TWO = "two"
|
||||
THREE = "three"
|
||||
FOUR = "four"
|
||||
FIVE = "five"
|
||||
SIX = "six"
|
||||
SEVEN = "seven"
|
||||
EIGHT = "eight"
|
||||
NINE = "nine"
|
||||
TEN = "ten"
|
||||
AMERICANO = "americano"
|
||||
CAPPUCCINO = "cappuccino"
|
||||
DOUBLE = "double"
|
||||
ESPRESSO = "espresso"
|
||||
DOUBLE_ESPRESSO = "double_espresso"
|
||||
LATTE = "latte"
|
||||
BLACK_TEA = "black_tea"
|
||||
FLOWER_TEA = "flower_tea"
|
||||
GREEN_TEA = "green_tea"
|
||||
HERBAL_TEA = "herbal_tea"
|
||||
OOLONG_TEA = "oolong_tea"
|
||||
PUERH_TEA = "puerh_tea"
|
||||
RED_TEA = "red_tea"
|
||||
WHITE_TEA = "white_tea"
|
||||
GLASS = "glass"
|
||||
INTENSIVE = "intensive"
|
||||
PRE_RINSE = "pre_rinse"
|
||||
ASPIC = "aspic"
|
||||
BABY_FOOD = "baby_food"
|
||||
BAKING = "baking"
|
||||
BREAD = "bread"
|
||||
BOILING = "boiling"
|
||||
CEREALS = "cereals"
|
||||
CHEESECAKE = "cheesecake"
|
||||
DEEP_FRYER = "deep_fryer"
|
||||
DESSERT = "dessert"
|
||||
FOWL = "fowl"
|
||||
FRYING = "frying"
|
||||
MACARONI = "macaroni"
|
||||
MILK_PORRIDGE = "milk_porridge"
|
||||
MULTICOOKER = "multicooker"
|
||||
PASTA = "pasta"
|
||||
PILAF = "pilaf"
|
||||
PIZZA = "pizza"
|
||||
SAUCE = "sauce"
|
||||
SLOW_COOK = "slow_cook"
|
||||
SOUP = "soup"
|
||||
STEAM = "steam"
|
||||
STEWING = "stewing"
|
||||
VACUUM = "vacuum"
|
||||
YOGURT = "yogurt"
|
||||
|
||||
|
||||
class ModeCapabilityParameters(APIModel):
|
||||
"""Parameters of a mode capability."""
|
||||
|
||||
instance: ModeCapabilityInstance
|
||||
modes: list[dict[Literal["value"], ModeCapabilityMode]]
|
||||
|
||||
@classmethod
|
||||
def from_list(cls, instance: ModeCapabilityInstance, modes: list[ModeCapabilityMode]) -> Self:
|
||||
return cls(instance=instance, modes=[{"value": m} for m in modes])
|
||||
|
||||
|
||||
class ModeCapabilityInstanceActionState(APIModel):
|
||||
"""New value for a mode capability."""
|
||||
|
||||
instance: ModeCapabilityInstance
|
||||
value: ModeCapabilityMode
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Schema for on_off capability.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/on_off.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from .base import APIModel
|
||||
|
||||
|
||||
class OnOffCapabilityInstance(StrEnum):
|
||||
"""Instance of an on_off capability."""
|
||||
|
||||
ON = "on"
|
||||
|
||||
|
||||
class OnOffCapabilityParameters(APIModel):
|
||||
"""Parameters of a on_off capability."""
|
||||
|
||||
split: bool
|
||||
|
||||
|
||||
class OnOffCapabilityInstanceActionState(APIModel):
|
||||
"""New value for an on_off capability."""
|
||||
|
||||
instance: OnOffCapabilityInstance
|
||||
value: bool
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Schema for range capability.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/range.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic.v1 import root_validator, validator
|
||||
|
||||
from .base import APIModel
|
||||
|
||||
|
||||
class RangeCapabilityUnit(StrEnum):
|
||||
"""Unit used in a range capability."""
|
||||
|
||||
PERCENT = "unit.percent"
|
||||
TEMPERATURE_CELSIUS = "unit.temperature.celsius"
|
||||
|
||||
|
||||
class RangeCapabilityInstance(StrEnum):
|
||||
"""Instance of a range capability.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/range-instance.html
|
||||
"""
|
||||
|
||||
BRIGHTNESS = "brightness"
|
||||
CHANNEL = "channel"
|
||||
HUMIDITY = "humidity"
|
||||
OPEN = "open"
|
||||
TEMPERATURE = "temperature"
|
||||
VOLUME = "volume"
|
||||
|
||||
|
||||
class RangeCapabilityRange(APIModel):
|
||||
"""Value range of a range capability."""
|
||||
|
||||
min: float
|
||||
max: float
|
||||
precision: float
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.min}, {self.max}]"
|
||||
|
||||
|
||||
class RangeCapabilityParameters(APIModel):
|
||||
"""Parameters of a range capability."""
|
||||
|
||||
instance: RangeCapabilityInstance
|
||||
unit: RangeCapabilityUnit | None = None
|
||||
random_access: bool
|
||||
range: RangeCapabilityRange | None = None
|
||||
|
||||
@root_validator
|
||||
def compute_unit(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return value unit for a capability instance."""
|
||||
match values.get("instance"):
|
||||
case RangeCapabilityInstance.BRIGHTNESS:
|
||||
values["unit"] = RangeCapabilityUnit.PERCENT
|
||||
case RangeCapabilityInstance.HUMIDITY:
|
||||
values["unit"] = RangeCapabilityUnit.PERCENT
|
||||
case RangeCapabilityInstance.OPEN:
|
||||
values["unit"] = RangeCapabilityUnit.PERCENT
|
||||
case RangeCapabilityInstance.TEMPERATURE:
|
||||
values["unit"] = RangeCapabilityUnit.TEMPERATURE_CELSIUS
|
||||
|
||||
return values
|
||||
|
||||
@root_validator
|
||||
def validate_range(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Force range boundaries for a capability instance."""
|
||||
|
||||
r: RangeCapabilityRange | None
|
||||
if r := values.get("range"):
|
||||
match values.get("instance"):
|
||||
case RangeCapabilityInstance.HUMIDITY | RangeCapabilityInstance.OPEN:
|
||||
r.min, r.max = max([0.0, r.min]), min([100.0, r.max])
|
||||
case RangeCapabilityInstance.BRIGHTNESS:
|
||||
r.min = max(min(r.min, 1.0), 0.0)
|
||||
r.max = 100.0
|
||||
r.precision = 1.0
|
||||
|
||||
return values
|
||||
|
||||
|
||||
class RangeCapabilityInstanceActionState(APIModel):
|
||||
"""New value for a range capability."""
|
||||
|
||||
instance: RangeCapabilityInstance
|
||||
value: float
|
||||
relative: bool = False
|
||||
|
||||
@validator("relative", pre=True, always=True)
|
||||
def set_relative(cls, v: Any) -> Any:
|
||||
"""Update relative value."""
|
||||
if v is None: # VK
|
||||
return False
|
||||
|
||||
return v
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Schema for toggle capability.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/toggle.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from .base import APIModel
|
||||
|
||||
|
||||
class ToggleCapabilityInstance(StrEnum):
|
||||
"""Instance of a toggle capability.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/toggle-instance.html
|
||||
"""
|
||||
|
||||
BACKLIGHT = "backlight"
|
||||
CONTROLS_LOCKED = "controls_locked"
|
||||
IONIZATION = "ionization"
|
||||
KEEP_WARM = "keep_warm"
|
||||
MUTE = "mute"
|
||||
OSCILLATION = "oscillation"
|
||||
PAUSE = "pause"
|
||||
|
||||
|
||||
class ToggleCapabilityParameters(APIModel):
|
||||
"""Parameters of a toggle capability."""
|
||||
|
||||
instance: ToggleCapabilityInstance
|
||||
|
||||
|
||||
class ToggleCapabilityInstanceActionState(APIModel):
|
||||
"""New value for a toggle capability."""
|
||||
|
||||
instance: ToggleCapabilityInstance
|
||||
value: bool
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Schema for video_stream capability.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/video_stream.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from .base import APIModel
|
||||
|
||||
StreamProtocols = list[Literal["hls"]]
|
||||
|
||||
|
||||
class VideoStreamCapabilityInstance(StrEnum):
|
||||
"""Instance of a video_stream capability."""
|
||||
|
||||
GET_STREAM = "get_stream"
|
||||
|
||||
|
||||
class VideoStreamCapabilityParameters(APIModel):
|
||||
"""Parameters of a video_stream capability."""
|
||||
|
||||
protocols: StreamProtocols
|
||||
|
||||
|
||||
class GetStreamInstanceActionStateValue(APIModel):
|
||||
"""New state value for a get_stream instance."""
|
||||
|
||||
protocols: StreamProtocols
|
||||
|
||||
|
||||
class GetStreamInstanceActionState(APIModel):
|
||||
"""New value for a get_stream instance."""
|
||||
|
||||
instance: Literal[VideoStreamCapabilityInstance.GET_STREAM]
|
||||
value: GetStreamInstanceActionStateValue
|
||||
|
||||
|
||||
class GetStreamInstanceActionResultValue(APIModel):
|
||||
"""New value after a get_stream instance state changed."""
|
||||
|
||||
stream_url: str
|
||||
protocol: Literal["hls"]
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Schema for an user device.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/reference/get-devices.html
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/reference/post-devices-query.html
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/reference/post-action.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any, Literal
|
||||
|
||||
from .base import APIModel
|
||||
from .capability import (
|
||||
CapabilityDescription,
|
||||
CapabilityInstance,
|
||||
CapabilityInstanceAction,
|
||||
CapabilityInstanceActionResultValue,
|
||||
CapabilityInstanceState,
|
||||
CapabilityType,
|
||||
)
|
||||
from .property import PropertyDescription, PropertyInstanceState
|
||||
from .response import ResponseCode, ResponsePayload
|
||||
|
||||
|
||||
class DeviceType(StrEnum):
|
||||
"""User device type.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/device-types.html
|
||||
"""
|
||||
|
||||
LIGHT = "devices.types.light"
|
||||
LIGHT_STRIP = "devices.types.light.strip"
|
||||
LIGHT_CEILING = "devices.types.light.ceiling"
|
||||
LIGHT_LAMP = "devices.types.light.lamp"
|
||||
LIGHT_GARLAND = "devices.types.light.garland"
|
||||
SOCKET = "devices.types.socket"
|
||||
SWITCH = "devices.types.switch"
|
||||
SWITCH_RELAY = "devices.types.switch.relay"
|
||||
THERMOSTAT = "devices.types.thermostat"
|
||||
THERMOSTAT_AC = "devices.types.thermostat.ac"
|
||||
MEDIA_DEVICE = "devices.types.media_device"
|
||||
MEDIA_DEVICE_TV = "devices.types.media_device.tv"
|
||||
MEDIA_DEVICE_TV_BOX = "devices.types.media_device.tv_box"
|
||||
MEDIA_DEVICE_RECIEVER = "devices.types.media_device.receiver"
|
||||
CAMERA = "devices.types.camera"
|
||||
COOKING = "devices.types.cooking"
|
||||
COFFEE_MAKER = "devices.types.cooking.coffee_maker"
|
||||
KETTLE = "devices.types.cooking.kettle"
|
||||
MULTICOOKER = "devices.types.cooking.multicooker"
|
||||
OPENABLE = "devices.types.openable"
|
||||
OPENABLE_CURTAIN = "devices.types.openable.curtain"
|
||||
OPENABLE_VALVE = "devices.types.openable.valve"
|
||||
HUMIDIFIER = "devices.types.humidifier"
|
||||
PURIFIER = "devices.types.purifier"
|
||||
VACUUM_CLEANER = "devices.types.vacuum_cleaner"
|
||||
WASHING_MACHINE = "devices.types.washing_machine"
|
||||
DISHWASHER = "devices.types.dishwasher"
|
||||
IRON = "devices.types.iron"
|
||||
SENSOR = "devices.types.sensor"
|
||||
SENSOR_MOTION = "devices.types.sensor.motion"
|
||||
SENSOR_VIBRATION = "devices.types.sensor.vibration"
|
||||
SENSOR_ILLUMINATION = "devices.types.sensor.illumination"
|
||||
SENSOR_OPEN = "devices.types.sensor.open"
|
||||
SENSOR_CLIMATE = "devices.types.sensor.climate"
|
||||
SENSOR_WATER_LEAK = "devices.types.sensor.water_leak"
|
||||
SENSOR_BUTTON = "devices.types.sensor.button"
|
||||
SENSOR_GAS = "devices.types.sensor.gas"
|
||||
SENSOR_SMOKE = "devices.types.sensor.smoke"
|
||||
SMART_METER = "devices.types.smart_meter"
|
||||
SMART_METER_COLD_WATER = "devices.types.smart_meter.cold_water"
|
||||
SMART_METER_ELECTRICITY = "devices.types.smart_meter.electricity"
|
||||
SMART_METER_GAS = "devices.types.smart_meter.gas"
|
||||
SMART_METER_HEAT = "devices.types.smart_meter.heat"
|
||||
SMART_METER_HOT_WATER = "devices.types.smart_meter.hot_water"
|
||||
PET_DRINKING_FOUNTAIN = "devices.types.pet_drinking_fountain"
|
||||
PET_FEEDER = "devices.types.pet_feeder"
|
||||
VENTILATION = "devices.types.ventilation"
|
||||
VENTILATION_FAN = "devices.types.ventilation.fan"
|
||||
OTHER = "devices.types.other"
|
||||
|
||||
|
||||
class DeviceInfo(APIModel):
|
||||
"""Extended device info."""
|
||||
|
||||
manufacturer: str | None = None
|
||||
model: str | None = None
|
||||
hw_version: str | None = None
|
||||
sw_version: str | None = None
|
||||
|
||||
|
||||
class DeviceDescription(APIModel):
|
||||
"""Device description for a device list request."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
room: str | None = None
|
||||
type: DeviceType
|
||||
capabilities: list[CapabilityDescription] | None = None
|
||||
properties: list[PropertyDescription] | None = None
|
||||
device_info: DeviceInfo | None = None
|
||||
|
||||
|
||||
class DeviceState(APIModel):
|
||||
"""Device state for a state query request."""
|
||||
|
||||
id: str
|
||||
capabilities: list[CapabilityInstanceState] | None = None
|
||||
properties: list[PropertyInstanceState] | None = None
|
||||
error_code: ResponseCode | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class DeviceList(ResponsePayload):
|
||||
"""Response payload for a device list request."""
|
||||
|
||||
user_id: str
|
||||
devices: list[DeviceDescription]
|
||||
|
||||
|
||||
class DeviceStates(ResponsePayload):
|
||||
"""Response payload for a state query request."""
|
||||
|
||||
devices: list[DeviceState]
|
||||
|
||||
|
||||
class StatesRequestDevice(APIModel):
|
||||
"""Device for a state query request."""
|
||||
|
||||
id: str
|
||||
custom_data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class StatesRequest(APIModel):
|
||||
"""Request body for a state query request."""
|
||||
|
||||
devices: list[StatesRequestDevice]
|
||||
|
||||
|
||||
class ActionRequestDevice(APIModel):
|
||||
"""Device for a state change request."""
|
||||
|
||||
id: str
|
||||
capabilities: list[CapabilityInstanceAction]
|
||||
|
||||
|
||||
class ActionRequestPayload(APIModel):
|
||||
"""Request payload for state change request."""
|
||||
|
||||
devices: list[ActionRequestDevice]
|
||||
|
||||
|
||||
class ActionRequest(APIModel):
|
||||
"""Request body for a state change request."""
|
||||
|
||||
payload: ActionRequestPayload
|
||||
|
||||
|
||||
class SuccessActionResult(APIModel):
|
||||
"""Success device action result."""
|
||||
|
||||
status: Literal["DONE"] = "DONE"
|
||||
|
||||
|
||||
class FailedActionResult(APIModel):
|
||||
"""Failed device action result."""
|
||||
|
||||
status: Literal["ERROR"] = "ERROR"
|
||||
error_code: ResponseCode
|
||||
|
||||
|
||||
class ActionResultCapabilityState(APIModel):
|
||||
"""Result of capability instance state change."""
|
||||
|
||||
instance: CapabilityInstance
|
||||
value: CapabilityInstanceActionResultValue | None = None
|
||||
action_result: SuccessActionResult | FailedActionResult
|
||||
|
||||
|
||||
class ActionResultCapability(APIModel):
|
||||
"""Result of capability state change."""
|
||||
|
||||
type: CapabilityType
|
||||
state: ActionResultCapabilityState
|
||||
|
||||
|
||||
class ActionResultDevice(APIModel):
|
||||
"""Device for a state change response."""
|
||||
|
||||
id: str
|
||||
capabilities: list[ActionResultCapability] | None = None
|
||||
action_result: FailedActionResult | SuccessActionResult | None = None
|
||||
|
||||
|
||||
class ActionResult(ResponsePayload):
|
||||
"""Response for a device state change."""
|
||||
|
||||
devices: list[ActionResultDevice]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Schema for device property.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/properties-types.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any, Literal
|
||||
|
||||
from .base import APIModel
|
||||
from .property_event import EventPropertyInstance, EventPropertyParameters
|
||||
from .property_float import FloatPropertyInstance, FloatPropertyParameters
|
||||
|
||||
|
||||
class PropertyType(StrEnum):
|
||||
"""Property type."""
|
||||
|
||||
FLOAT = "devices.properties.float"
|
||||
EVENT = "devices.properties.event"
|
||||
|
||||
@property
|
||||
def short(self) -> str:
|
||||
"""Return short version of the property type."""
|
||||
return str(self).replace("devices.properties.", "")
|
||||
|
||||
|
||||
class FloatPropertyDescription(APIModel):
|
||||
"""Description of a float property for a device list request."""
|
||||
|
||||
type: Literal[PropertyType.FLOAT] = PropertyType.FLOAT
|
||||
retrievable: bool
|
||||
reportable: bool
|
||||
parameters: FloatPropertyParameters
|
||||
|
||||
|
||||
class EventPropertyDescription(APIModel):
|
||||
"""Description of an event property for a device list request."""
|
||||
|
||||
type: Literal[PropertyType.EVENT] = PropertyType.EVENT
|
||||
retrievable: bool
|
||||
reportable: bool
|
||||
parameters: EventPropertyParameters[Any]
|
||||
|
||||
|
||||
PropertyDescription = FloatPropertyDescription | EventPropertyDescription
|
||||
"""Description of a property for a device list request."""
|
||||
|
||||
PropertyParameters = FloatPropertyParameters | EventPropertyParameters[Any]
|
||||
"""Parameters of a property for a device list request."""
|
||||
|
||||
PropertyInstance = FloatPropertyInstance | EventPropertyInstance
|
||||
"""All property instances."""
|
||||
|
||||
|
||||
class PropertyInstanceStateValue(APIModel):
|
||||
"""Property instance value."""
|
||||
|
||||
instance: PropertyInstance
|
||||
value: Any
|
||||
|
||||
|
||||
class PropertyInstanceState(APIModel):
|
||||
"""Property state for state query and callback requests."""
|
||||
|
||||
type: PropertyType
|
||||
state: PropertyInstanceStateValue
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Schema for event property.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/event.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
|
||||
from pydantic.v1 import validator
|
||||
|
||||
from .base import GenericAPIModel
|
||||
|
||||
|
||||
class EventPropertyInstance(StrEnum):
|
||||
"""Instance of an event property.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/event-instance.html
|
||||
"""
|
||||
|
||||
VIBRATION = "vibration"
|
||||
OPEN = "open"
|
||||
BUTTON = "button"
|
||||
MOTION = "motion"
|
||||
SMOKE = "smoke"
|
||||
GAS = "gas"
|
||||
BATTERY_LEVEL = "battery_level"
|
||||
FOOD_LEVEL = "food_level"
|
||||
WATER_LEVEL = "water_level"
|
||||
WATER_LEAK = "water_leak"
|
||||
|
||||
|
||||
class EventInstanceEvent(StrEnum):
|
||||
"""Base class for an instance event."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class VibrationInstanceEvent(EventInstanceEvent):
|
||||
"""Event of a vibration instance."""
|
||||
|
||||
TILT = "tilt"
|
||||
FALL = "fall"
|
||||
VIBRATION = "vibration"
|
||||
|
||||
|
||||
class OpenInstanceEvent(EventInstanceEvent):
|
||||
"""Event of a open instance."""
|
||||
|
||||
OPENED = "opened"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class ButtonInstanceEvent(EventInstanceEvent):
|
||||
"""Event of a button instance."""
|
||||
|
||||
CLICK = "click"
|
||||
DOUBLE_CLICK = "double_click"
|
||||
LONG_PRESS = "long_press"
|
||||
|
||||
|
||||
class MotionInstanceEvent(EventInstanceEvent):
|
||||
"""Event of a motion instance."""
|
||||
|
||||
DETECTED = "detected"
|
||||
NOT_DETECTED = "not_detected"
|
||||
|
||||
|
||||
class SmokeInstanceEvent(EventInstanceEvent):
|
||||
"""Event of a smoke instance."""
|
||||
|
||||
DETECTED = "detected"
|
||||
NOT_DETECTED = "not_detected"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class GasInstanceEvent(EventInstanceEvent):
|
||||
"""Event of a gas instance."""
|
||||
|
||||
DETECTED = "detected"
|
||||
NOT_DETECTED = "not_detected"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class BatteryLevelInstanceEvent(EventInstanceEvent):
|
||||
"""Event of a battery_level instance."""
|
||||
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
|
||||
|
||||
class FoodLevelInstanceEvent(EventInstanceEvent):
|
||||
"""Event of a food_level instance."""
|
||||
|
||||
EMPTY = "empty"
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
|
||||
|
||||
class WaterLevelInstanceEvent(EventInstanceEvent):
|
||||
"""Event of a water_level instance."""
|
||||
|
||||
EMPTY = "empty"
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
|
||||
|
||||
class WaterLeakInstanceEvent(EventInstanceEvent):
|
||||
"""Event of a water_leak instance."""
|
||||
|
||||
DRY = "dry"
|
||||
LEAK = "leak"
|
||||
|
||||
|
||||
EventInstanceEventT = TypeVar("EventInstanceEventT", bound=EventInstanceEvent)
|
||||
|
||||
|
||||
def get_event_class_for_instance(instance: EventPropertyInstance) -> type[EventInstanceEvent]:
|
||||
"""Return EventInstanceEvent enum for event property instance."""
|
||||
return {
|
||||
EventPropertyInstance.VIBRATION: VibrationInstanceEvent,
|
||||
EventPropertyInstance.OPEN: OpenInstanceEvent,
|
||||
EventPropertyInstance.BUTTON: ButtonInstanceEvent,
|
||||
EventPropertyInstance.MOTION: MotionInstanceEvent,
|
||||
EventPropertyInstance.SMOKE: SmokeInstanceEvent,
|
||||
EventPropertyInstance.GAS: GasInstanceEvent,
|
||||
EventPropertyInstance.BATTERY_LEVEL: BatteryLevelInstanceEvent,
|
||||
EventPropertyInstance.FOOD_LEVEL: FoodLevelInstanceEvent,
|
||||
EventPropertyInstance.WATER_LEVEL: WaterLevelInstanceEvent,
|
||||
EventPropertyInstance.WATER_LEAK: WaterLeakInstanceEvent,
|
||||
}[instance]
|
||||
|
||||
|
||||
def get_supported_events_for_instance(instance: EventPropertyInstance) -> list[EventInstanceEvent]:
|
||||
"""Return list of supported events for event property instance."""
|
||||
return list(get_event_class_for_instance(instance).__members__.values())
|
||||
|
||||
|
||||
class EventPropertyParameters(GenericAPIModel, Generic[EventInstanceEventT]):
|
||||
"""Parameters of an event property."""
|
||||
|
||||
instance: EventPropertyInstance
|
||||
events: list[dict[Literal["value"], EventInstanceEventT]] = []
|
||||
|
||||
@validator("events", pre=True, always=True)
|
||||
def set_events(cls, v: Any) -> Any:
|
||||
"""Update events list value."""
|
||||
if not v:
|
||||
instance_event: type[EventInstanceEventT] = cls.__fields__["events"].type_.__args__[1]
|
||||
return [{"value": m} for m in instance_event.__members__.values()]
|
||||
|
||||
return v # pragma: nocover
|
||||
|
||||
|
||||
class VibrationEventPropertyParameters(EventPropertyParameters[VibrationInstanceEvent]):
|
||||
instance: Literal[EventPropertyInstance.VIBRATION] = EventPropertyInstance.VIBRATION
|
||||
|
||||
|
||||
class OpenEventPropertyParameters(EventPropertyParameters[OpenInstanceEvent]):
|
||||
instance: Literal[EventPropertyInstance.OPEN] = EventPropertyInstance.OPEN
|
||||
|
||||
|
||||
class ButtonEventPropertyParameters(EventPropertyParameters[ButtonInstanceEvent]):
|
||||
instance: Literal[EventPropertyInstance.BUTTON] = EventPropertyInstance.BUTTON
|
||||
|
||||
|
||||
class MotionEventPropertyParameters(EventPropertyParameters[MotionInstanceEvent]):
|
||||
instance: Literal[EventPropertyInstance.MOTION] = EventPropertyInstance.MOTION
|
||||
|
||||
|
||||
class SmokeEventPropertyParameters(EventPropertyParameters[SmokeInstanceEvent]):
|
||||
instance: Literal[EventPropertyInstance.SMOKE] = EventPropertyInstance.SMOKE
|
||||
|
||||
|
||||
class GasEventPropertyParameters(EventPropertyParameters[GasInstanceEvent]):
|
||||
instance: Literal[EventPropertyInstance.GAS] = EventPropertyInstance.GAS
|
||||
|
||||
|
||||
class BatteryLevelEventPropertyParameters(EventPropertyParameters[BatteryLevelInstanceEvent]):
|
||||
instance: Literal[EventPropertyInstance.BATTERY_LEVEL] = EventPropertyInstance.BATTERY_LEVEL
|
||||
|
||||
|
||||
class FoodLevelEventPropertyParameters(EventPropertyParameters[FoodLevelInstanceEvent]):
|
||||
instance: Literal[EventPropertyInstance.FOOD_LEVEL] = EventPropertyInstance.FOOD_LEVEL
|
||||
|
||||
|
||||
class WaterLevelEventPropertyParameters(EventPropertyParameters[WaterLevelInstanceEvent]):
|
||||
instance: Literal[EventPropertyInstance.WATER_LEVEL] = EventPropertyInstance.WATER_LEVEL
|
||||
|
||||
|
||||
class WaterLeakEventPropertyParameters(EventPropertyParameters[WaterLeakInstanceEvent]):
|
||||
instance: Literal[EventPropertyInstance.WATER_LEAK] = EventPropertyInstance.WATER_LEAK
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Schema for float property.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/float.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from .base import APIModel
|
||||
|
||||
|
||||
class FloatPropertyInstance(StrEnum):
|
||||
"""Instance of an event property.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/float-instance.html
|
||||
"""
|
||||
|
||||
AMPERAGE = "amperage"
|
||||
BATTERY_LEVEL = "battery_level"
|
||||
CO2_LEVEL = "co2_level"
|
||||
ELECTRICITY_METER = "electricity_meter"
|
||||
FOOD_LEVEL = "food_level"
|
||||
GAS_METER = "gas_meter"
|
||||
HEAT_METER = "heat_meter"
|
||||
HUMIDITY = "humidity"
|
||||
ILLUMINATION = "illumination"
|
||||
METER = "meter"
|
||||
PM10_DENSITY = "pm10_density"
|
||||
PM1_DENSITY = "pm1_density"
|
||||
PM2_5_DENSITY = "pm2.5_density"
|
||||
POWER = "power"
|
||||
PRESSURE = "pressure"
|
||||
TEMPERATURE = "temperature"
|
||||
TVOC = "tvoc"
|
||||
VOLTAGE = "voltage"
|
||||
WATER_LEVEL = "water_level"
|
||||
WATER_METER = "water_meter"
|
||||
|
||||
|
||||
class FloatUnit(StrEnum):
|
||||
"""Unit used in a float property."""
|
||||
|
||||
AMPERE = "unit.ampere"
|
||||
CUBIC_METER = "unit.cubic_meter"
|
||||
GIGACALORIE = "unit.gigacalorie"
|
||||
KILOWATT_HOUR = "unit.kilowatt_hour"
|
||||
LUX = "unit.illumination.lux"
|
||||
MCG_M3 = "unit.density.mcg_m3"
|
||||
PERCENT = "unit.percent"
|
||||
PPM = "unit.ppm"
|
||||
VOLT = "unit.volt"
|
||||
WATT = "unit.watt"
|
||||
|
||||
|
||||
class PressureUnit(StrEnum):
|
||||
"""Pressure unit."""
|
||||
|
||||
PASCAL = "unit.pressure.pascal"
|
||||
MMHG = "unit.pressure.mmhg"
|
||||
ATM = "unit.pressure.atm"
|
||||
BAR = "unit.pressure.bar"
|
||||
|
||||
|
||||
class TemperatureUnit(StrEnum):
|
||||
"""Temperature unit."""
|
||||
|
||||
CELSIUS = "unit.temperature.celsius"
|
||||
KELVIN = "unit.temperature.kelvin"
|
||||
|
||||
|
||||
class FloatPropertyParameters(APIModel):
|
||||
"""Parameters of a float property."""
|
||||
|
||||
instance: FloatPropertyInstance
|
||||
unit: FloatUnit | PressureUnit | TemperatureUnit | None
|
||||
|
||||
@property
|
||||
def range(self) -> tuple[int | None, int | None]:
|
||||
"""Return value range."""
|
||||
return None, None
|
||||
|
||||
|
||||
class FloatPropertyAboveZeroMixin:
|
||||
"""Mixin for a property that has value only above zero."""
|
||||
|
||||
@property
|
||||
def range(self) -> tuple[int | None, int | None]:
|
||||
"""Return value range."""
|
||||
return 0, None
|
||||
|
||||
|
||||
class PercentFloatPropertyParameters(FloatPropertyParameters):
|
||||
unit: Literal[FloatUnit.PERCENT] = FloatUnit.PERCENT
|
||||
|
||||
@property
|
||||
def range(self) -> tuple[int | None, int | None]:
|
||||
"""Return value range."""
|
||||
return 0, 100
|
||||
|
||||
|
||||
class DensityFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
unit: Literal[FloatUnit.MCG_M3] = FloatUnit.MCG_M3
|
||||
|
||||
|
||||
class AmperageFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.AMPERAGE] = FloatPropertyInstance.AMPERAGE
|
||||
unit: Literal[FloatUnit.AMPERE] = FloatUnit.AMPERE
|
||||
|
||||
|
||||
class BatteryLevelFloatPropertyParameters(PercentFloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.BATTERY_LEVEL] = FloatPropertyInstance.BATTERY_LEVEL
|
||||
|
||||
|
||||
class CO2LevelFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.CO2_LEVEL] = FloatPropertyInstance.CO2_LEVEL
|
||||
unit: Literal[FloatUnit.PPM] = FloatUnit.PPM
|
||||
|
||||
|
||||
class ElectricityMeterFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.ELECTRICITY_METER] = FloatPropertyInstance.ELECTRICITY_METER
|
||||
unit: Literal[FloatUnit.KILOWATT_HOUR] = FloatUnit.KILOWATT_HOUR
|
||||
|
||||
|
||||
class FoodLevelFloatPropertyParameters(PercentFloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.FOOD_LEVEL] = FloatPropertyInstance.FOOD_LEVEL
|
||||
|
||||
|
||||
class GasMeterFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.GAS_METER] = FloatPropertyInstance.GAS_METER
|
||||
unit: Literal[FloatUnit.CUBIC_METER] = FloatUnit.CUBIC_METER
|
||||
|
||||
|
||||
class HeatMeterFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.HEAT_METER] = FloatPropertyInstance.HEAT_METER
|
||||
unit: Literal[FloatUnit.GIGACALORIE] = FloatUnit.GIGACALORIE
|
||||
|
||||
|
||||
class HumidityFloatPropertyParameters(PercentFloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.HUMIDITY] = FloatPropertyInstance.HUMIDITY
|
||||
|
||||
|
||||
class IlluminationFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.ILLUMINATION] = FloatPropertyInstance.ILLUMINATION
|
||||
unit: Literal[FloatUnit.LUX] = FloatUnit.LUX
|
||||
|
||||
|
||||
class MeterFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.METER] = FloatPropertyInstance.METER
|
||||
unit: None = None
|
||||
|
||||
|
||||
class PM1DensityFloatPropertyParameters(DensityFloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.PM1_DENSITY] = FloatPropertyInstance.PM1_DENSITY
|
||||
|
||||
|
||||
class PM25DensityFloatPropertyParameters(DensityFloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.PM2_5_DENSITY] = FloatPropertyInstance.PM2_5_DENSITY
|
||||
|
||||
|
||||
class PM10DensityFloatPropertyParameters(DensityFloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.PM10_DENSITY] = FloatPropertyInstance.PM10_DENSITY
|
||||
|
||||
|
||||
class PowerFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.POWER] = FloatPropertyInstance.POWER
|
||||
unit: Literal[FloatUnit.WATT] = FloatUnit.WATT
|
||||
|
||||
|
||||
class PressureFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.PRESSURE] = FloatPropertyInstance.PRESSURE
|
||||
unit: PressureUnit
|
||||
|
||||
|
||||
class TemperatureFloatPropertyParameters(FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.TEMPERATURE] = FloatPropertyInstance.TEMPERATURE
|
||||
unit: TemperatureUnit
|
||||
|
||||
|
||||
class TVOCFloatPropertyParameters(DensityFloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.TVOC] = FloatPropertyInstance.TVOC
|
||||
|
||||
|
||||
class VoltageFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.VOLTAGE] = FloatPropertyInstance.VOLTAGE
|
||||
unit: Literal[FloatUnit.VOLT] = FloatUnit.VOLT
|
||||
|
||||
|
||||
class WaterLevelFloatPropertyParameters(PercentFloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.WATER_LEVEL] = FloatPropertyInstance.WATER_LEVEL
|
||||
|
||||
|
||||
class WaterMeterFloatPropertyParameters(FloatPropertyAboveZeroMixin, FloatPropertyParameters):
|
||||
instance: Literal[FloatPropertyInstance.WATER_METER] = FloatPropertyInstance.WATER_METER
|
||||
unit: Literal[FloatUnit.CUBIC_METER] = FloatUnit.CUBIC_METER
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Schema for an API response.
|
||||
|
||||
https://yandex.ru/dev/dialogs/smart-home/doc/concepts/response-codes.html
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from .base import APIModel
|
||||
|
||||
|
||||
class ResponseCode(StrEnum):
|
||||
"""Response code."""
|
||||
|
||||
DOOR_OPEN = "DOOR_OPEN"
|
||||
LID_OPEN = "LID_OPEN"
|
||||
REMOTE_CONTROL_DISABLED = "REMOTE_CONTROL_DISABLED"
|
||||
NOT_ENOUGH_WATER = "NOT_ENOUGH_WATER"
|
||||
LOW_CHARGE_LEVEL = "LOW_CHARGE_LEVEL"
|
||||
CONTAINER_FULL = "CONTAINER_FULL"
|
||||
CONTAINER_EMPTY = "CONTAINER_EMPTY"
|
||||
DRIP_TRAY_FULL = "DRIP_TRAY_FULL"
|
||||
DEVICE_STUCK = "DEVICE_STUCK"
|
||||
DEVICE_OFF = "DEVICE_OFF"
|
||||
FIRMWARE_OUT_OF_DATE = "FIRMWARE_OUT_OF_DATE"
|
||||
NOT_ENOUGH_DETERGENT = "NOT_ENOUGH_DETERGENT"
|
||||
HUMAN_INVOLVEMENT_NEEDED = "HUMAN_INVOLVEMENT_NEEDED"
|
||||
DEVICE_UNREACHABLE = "DEVICE_UNREACHABLE"
|
||||
DEVICE_BUSY = "DEVICE_BUSY"
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||
INVALID_ACTION = "INVALID_ACTION"
|
||||
INVALID_VALUE = "INVALID_VALUE"
|
||||
NOT_SUPPORTED_IN_CURRENT_MODE = "NOT_SUPPORTED_IN_CURRENT_MODE"
|
||||
ACCOUNT_LINKING_ERROR = "ACCOUNT_LINKING_ERROR"
|
||||
DEVICE_NOT_FOUND = "DEVICE_NOT_FOUND"
|
||||
|
||||
|
||||
class ResponsePayload(APIModel):
|
||||
"""Base class for an API response payload."""
|
||||
|
||||
|
||||
class Error(ResponsePayload):
|
||||
"""Error payload."""
|
||||
|
||||
error_code: ResponseCode
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class Response(APIModel):
|
||||
"""Base API response."""
|
||||
|
||||
request_id: str | None = None
|
||||
payload: ResponsePayload | None = None
|
||||
@@ -0,0 +1,2 @@
|
||||
reload:
|
||||
description: Reload yandex_smart_home yaml configuration.
|
||||
@@ -0,0 +1,350 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"missing_external_url": "**Использование прямого подключения невозможно**: не задан внешний URL-адрес сервера Home Assistant.\n\nДля исправления:\n* Включите расширенный режим в [настройках профиля](https://my.home-assistant.io/redirect/profile/)\n* Задайте URL-адрес сервера в Настройки > Система > [Сеть](https://my.home-assistant.io/redirect/network/) или в параметре [`external_url`](https://www.home-assistant.io/integrations/homeassistant/#external_url) в `configuration.yaml`\n* Повторно добавьте интеграцию"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Не удалось подключиться, проверьте журнал сервера",
|
||||
"entities_not_selected": "Необходимо выбрать хотя бы один объект",
|
||||
"missing_config_entry": "Не найдено подходящих интеграций",
|
||||
"already_configured": "Для этого пользователя уже настроена интеграция {entry_title}"
|
||||
},
|
||||
"create_entry": {
|
||||
"direct_yandex": "**Прямое подключение настроено!**\n\nТеперь вы можете добавить Home Assistant в Умный дом Яндекса, для этого:\n * Откройте приложение [Дом с Алисой](https://ya.cc/iot_app)\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Устройство умного дома\"\n* Найдите в списке и выберите навык, который вы создали\n* Нажмите кнопку \"Привязать к Яндексу\", откроется страница авторизации\n* Выполните привязку используя имя пользователя, указанное при настройке интеграции",
|
||||
"direct_vk": "**Прямое подключение настроено!**\n\nТеперь вы можете управлять устройствами Home Assistant через Марусю, для этого:\n* Откройте приложение [Маруся](https://trk.mail.ru/c/u2dc49)\n* Нажмите иконку \"Домик\" в правом верхнем углу\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Подключить устройство\"\n* Найдите в списке и выберите приложение, которое вы создали\n* Откроется страница авторизации\n* Выполните привязку используя имя пользователя, указанное при настройке интеграции",
|
||||
"cloud": "**Облачное подключение настроено!**\n\nРеквизиты для привязки Home Assistant к навыку Yaha Cloud:\n* **Одноразовый код**: `{otp}`\n\nили:\n* **ID**: `{id}`\n* **Пароль**: `{password}`\n\nРеквизиты продублированы в настройках интеграции, сохранять их не требуется.\n\nТеперь вы можете добавить Home Assistant в Умный дом Яндекса, для этого:\n * Откройте приложение [Дом с Алисой](https://ya.cc/iot_app)\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Устройство умного дома\"\n* Найдите в списке и выберите производителя \"Yaha Cloud\"\n* Нажмите кнопку \"Привязать к Яндексу\", откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nДля управления устройствами через Марусю:\n* Откройте приложение [Маруся](https://trk.mail.ru/c/u2dc49)\n* Нажмите иконку \"Домик\" в правом верхнем углу\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Подключить устройство\"\n* Найдите в списке и выберите бренд \"Yaha Cloud\"\n* Откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nВ [настройках интеграции](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui) вы можете выбрать пользователя, который будет отображаться в Журнале событий.",
|
||||
"cloud_plus_yandex": "**Облачное Плюс подключение настроено!**\n\nРеквизиты для привязки Home Assistant к навыку {skill}:\n* **Одноразовый код**: `{otp}`\n\nили:\n* **ID**: `{id}`\n* **Пароль**: `{password}`\n\nРеквизиты продублированы в настройках интеграции, сохранять их не требуется.\n\nТеперь вы можете добавить Home Assistant в Умный дом Яндекса, для этого:\n * Откройте приложение [Дом с Алисой](https://ya.cc/iot_app)\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Устройство умного дома\"\n* Найдите в списке и выберите производителя \"{skill}\"\n* Нажмите кнопку \"Привязать к Яндексу\", откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nВ [настройках интеграции](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui) вы можете выбрать пользователя, который будет отображаться в Журнале событий.",
|
||||
"cloud_plus_vk": "**Облачное Плюс подключение настроено!**\n\nРеквизиты для привязки Home Assistant к приложению {skill}:\n* **Одноразовый код**: `{otp}`\n\nили:\n* **ID**: `{id}`\n* **Пароль**: `{password}`\n\nРеквизиты продублированы в настройках интеграции, сохранять их не требуется.\n\nТеперь вы можете управлять устройствами Home Assistant через Марусю, для этого:\n * Откройте приложение [Маруся](https://trk.mail.ru/c/u2dc49)\n* Нажмите иконку \"Домик\" в правом верхнем углу\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Подключить устройство\"\n* Найдите в списке и выберите бренд \"{skill}\"\n* Откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nВ [настройках интеграции](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui) вы можете выбрать пользователя, который будет отображаться в Журнале событий."
|
||||
},
|
||||
"step": {
|
||||
"user" : {
|
||||
"description": "Добро пожаловать в мастер добавления интеграции Yandex Smart Home!\n\nПосле добавления интеграции вы можете изменить любые её параметры в [настройках](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui), удалять интеграцию и настраивать заново не требуется.\n\nПолезные ссылки:\n* Документация: https://docs.yaha-cloud.ru/v1.0.x/\n* Чат в Телеграм: https://t.me/yandex_smart_home"
|
||||
},
|
||||
"connection_type": {
|
||||
"title": "Выберите тип подключения",
|
||||
"description": "* **Облачное**: лёгкое и быстрое подключение через навык [Yaha Cloud](https://dialogs.yandex.ru/store/skills/cef326b2-home-assistant), рекомендуется для всех пользователей\n* **Облачное Плюс**: для подключения дополнительного Home Assistant через облако Yaha Cloud, потребуется создать навык вручную (не используйте этот тип, если у вас только один Home Assistant)\n* **Прямое**: требуется доступ к Home Assistant по HTTPS через интернет, сложная [многоступенчатая настройка](https://docs.yaha-cloud.ru/v1.0.x/advanced/direct-connection/), только для продвинутых пользователей",
|
||||
"data": {
|
||||
"connection_type": "Тип подключения"
|
||||
},
|
||||
"data_description": {
|
||||
"connection_type": "\\* [Какой тип подключения лучше?](https://docs.yaha-cloud.ru/v1.0.x/config/connection-type/#compare)\n\\* [Подключение нескольких HA](https://docs.yaha-cloud.ru/v1.0.x/config/multi-ha/)"
|
||||
}
|
||||
},
|
||||
"platform_direct": {
|
||||
"title": "Прямое подключение",
|
||||
"description": "Перед настройкой прямого подключения убедитесь, что сервер Home Assistant доступен из интернета и локальной сети по адресу **`{external_url}`** ([подробнее](https://docs.yaha-cloud.ru/v1.0.x/advanced/direct-connection/) о требованиях и как проверить доступность)",
|
||||
"data": {
|
||||
"platform": "Платформа умного дома"
|
||||
}
|
||||
},
|
||||
"platform_cloud_plus": {
|
||||
"title": "Платформа умного дома",
|
||||
"data": {
|
||||
"platform": " "
|
||||
}
|
||||
},
|
||||
"skill_yandex_direct": {
|
||||
"title": "Параметры навыка",
|
||||
"description": "Для интеграции требуется создать навык на платформе Яндекс Диалоги:\n\n1\\. Зайдите в консоль [Яндекс Диалоги](https://dialogs.yandex.ru/developer) > Создать диалог > Умный дом\n2\\. Заполните параметры на вкладке **Настройки**:\n* Язык: **`Русский`**\n* Название: **`Home Assistant`** (или другое)\n* Подключение устройств: `Любой текст`\n* Backend: `{external_url}/api/yandex_smart_home`\n* Тип доступа: **`Приватный`**\n* Имя разработчика: **`Любое`**\n* Иконка: **`Любая`** (например [эта](https://community-assets.home-assistant.io/original/3X/6/a/6a99ebb8d0b585a00b407123ff76964cb3e18780.png))\n\n3\\. Нажмите **Сохранить** и заполните на вкладке **Связка аккаунтов**:\n* Идентификатор приложения: `https://social.yandex.net/`\n* Секрет приложения: **`secret`**\n* URL авторизации: `{external_url}/auth/authorize`\n* URL для получения токена: `{external_url}/auth/token`\n* URL для обновления токена: `{external_url}/auth/token`\n\n4\\. Нажмите **Сохранить**, перейдите на вкладку **Общие сведения**\n5\\. Нажмите **Опубликовать**\n6\\. Появились ошибки валидации? Не страшно, [устраните](https://docs.yaha-cloud.ru/v1.0.x/advanced/direct-connection/#validation-errors) их **после** завершения мастера настройки",
|
||||
"data": {
|
||||
"user_id": "Пользователь",
|
||||
"id": "Идентификатор диалога",
|
||||
"token": "Токен"
|
||||
},
|
||||
"data_description": {
|
||||
"user_id": "Пользователь Home Assistant, который будет использовать эту интеграцию\nПри связывании аккаунтов **обязательно** использовать его при входе\nСоздать пользователя можно в Настройки > Люди > Добавить персону (отметить Разрешить вход в систему)",
|
||||
"id": "С вкладки **Общие сведения** настроек вашего навыка (в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer))",
|
||||
"token": "Получите по [ссылке](https://oauth.yandex.ru/authorize?response_type=token&client_id=c473ca268cd749d3a8371351a8f2bcbd)\nУбедитесь, что вошли в Яндекс под аккаунтом владельца диалога"
|
||||
}
|
||||
},
|
||||
"skill_vk_direct": {
|
||||
"title": "Параметры приложения",
|
||||
"description": "Для интеграции требуется создать приложение на платформе VK для разработчиков:\n\n1\\. Зайдите на [platform.vk.com](https://platform.vk.com) и зарегистрируйтесь как физическое лицо\n2\\. Создайте проект с любым названием\n3\\. Перейдите в созданный проект > Добавить приложение:\n* Название: **`Home Assistant`** (или другое)\n* Тип: `Умный дом с Марусей`\n\n4\\. Перейдите в созданное приложение и заполните параметры:\n* Адрес API: `{external_url}/api/yandex_smart_home/v1.0`\n* Адрес страницы авторизации: `{external_url}/auth/authorize`\n* Адрес для получения тоĸена: `{external_url}/auth/token`\n* Адрес для обновления токена: `{external_url}/auth/token`\n* Авторизационный client_id: `https://vc.go.mail.ru`\n* Авторизационный secret: `secret`\n* Протокол интеграции: `Яндекс`\n\n5\\. Нажмите **Сохранить изменения**",
|
||||
"data": {
|
||||
"user_id": "Пользователь",
|
||||
"id": "ID приложения"
|
||||
},
|
||||
"data_description": {
|
||||
"user_id": "Пользователь Home Assistant, который будет использовать эту интеграцию\nПри связывании аккаунтов **обязательно** использовать его при входе\nСоздать пользователя можно в Настройки > Люди > Добавить персону (отметить Разрешить вход в систему)",
|
||||
"id": "Из настроек приложения на платформе [VK для разработчиков](https://platform.vk.com)"
|
||||
}
|
||||
},
|
||||
"skill_yandex_cloud_plus": {
|
||||
"title": "Параметры навыка",
|
||||
"description": "Для интеграции требуется создать навык на платформе Яндекс Диалоги:\n\n1\\. Зайдите в консоль [Яндекс Диалоги](https://dialogs.yandex.ru/developer) > Создать диалог > Умный дом\n2\\. Заполните параметры на вкладке **Настройки**:\n* Язык: **`Русский`**\n* Название: **`Yaha Cloud Plus`** (или другое)\n* Подключение устройств: `Любой текст`\n* Backend: `{cloud_base_url}/api/yandex_smart_home`\n* Тип доступа: **`Приватный`**\n* Имя разработчика: **`Любое`**\n* Иконка: **`Любая`** (например [вариант 1](https://docs.yaha-cloud.ru/v1.0.x/assets/logo/logo-plus-1.png), [вариант 2](https://docs.yaha-cloud.ru/v1.0.x/assets/logo/logo-plus-2.png))\n\n3\\. Нажмите **Сохранить** и заполните на вкладке **Связка аккаунтов**:\n* Идентификатор приложения: `yandex_smart_home:{instance_id}`\n* Секрет приложения: **`secret`**\n* URL авторизации: `{cloud_base_url}/oauth/authorize`\n* URL для получения токена: `{cloud_base_url}/oauth/token`\n* URL для обновления токена: `{cloud_base_url}/oauth/token`\n\n4\\. Нажмите **Сохранить**, перейдите на вкладку **Общие сведения**\n5\\. Нажмите **Опубликовать**",
|
||||
"data": {
|
||||
"name": "Название диалога",
|
||||
"id": "Идентификатор диалога",
|
||||
"token": "Токен"
|
||||
},
|
||||
"data_description": {
|
||||
"id": "С вкладки **Общие сведения** настроек вашего навыка (в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer))",
|
||||
"token": "Получите по [ссылке](https://oauth.yandex.ru/authorize?response_type=token&client_id=c473ca268cd749d3a8371351a8f2bcbd)\nУбедитесь, что вошли в Яндекс под аккаунтом владельца диалога"
|
||||
}
|
||||
},
|
||||
"skill_vk_cloud_plus": {
|
||||
"title": "Параметры навыка",
|
||||
"description": "Для интеграции требуется создать приложение на платформе VK для разработчиков:\n\n1\\. Зайдите на [platform.vk.com](https://platform.vk.com) и зарегистрируйтесь как физическое лицо\n2\\. Создайте проект с любым названием\n3\\. Перейдите в созданный проект > Добавить приложение:\n* Название: **`Yaha Cloud Plus`** (или другое)\n* Тип: `Умный дом с Марусей`\n\n4\\. Перейдите в созданное приложение и заполните параметры:\n* Адрес API: `{cloud_base_url}/api/yandex_smart_home/v1.0`\n* Адрес страницы авторизации: `{cloud_base_url}/oauth/authorize`\n* Адрес для получения тоĸена: `{cloud_base_url}/oauth/token`\n* Адрес для обновления токена: `{cloud_base_url}/oauth/token`\n* Авторизационный client_id: `vk_smart_home:{instance_id}`\n* Авторизационный secret: `secret`\n* Протокол интеграции: `Яндекс`\n\n5\\. Нажмите **Сохранить изменения**",
|
||||
"data": {
|
||||
"name": "Название приложения",
|
||||
"id": "ID приложения"
|
||||
},
|
||||
"data_description": {
|
||||
"id": "Из настроек приложения на платформе [VK для разработчиков](https://platform.vk.com)"
|
||||
}
|
||||
},
|
||||
"expose_settings": {
|
||||
"title": "Объекты для передачи в УДЯ",
|
||||
"data": {
|
||||
"filter_source": "Способ выбора объектов для передачи в УДЯ",
|
||||
"entry_aliases": "Учитывать альтернативные названия устройств и комнат"
|
||||
},
|
||||
"data_description": {
|
||||
"entry_aliases": "Отключите этот параметр, если используете альтернативные названия для функции Assist.\nСм. также: [особенности именования устройств и комнат](https://docs.yaha-cloud.ru/v1.0.x/quirks#naming)"
|
||||
}
|
||||
},
|
||||
"update_filter": {
|
||||
"title": "Объекты для передачи в УДЯ",
|
||||
"description": "Выберите интеграцию с которой будут скопированы объекты для передачи в УДЯ. На следующем шаге вы сможете изменить этот список.",
|
||||
"data": {
|
||||
"id": "Интеграция",
|
||||
"filter_source": "Выбрать другой способ выбора объектов для передачи в УДЯ"
|
||||
}
|
||||
},
|
||||
"include_entities": {
|
||||
"title": "Объекты для передачи в УДЯ",
|
||||
"description": "Выберите объекты, которые будут переданы в УДЯ/Марусю в виде устройств.\n\nПри начальной настройке рекомендуется сократить этот список до минимума.",
|
||||
"data": {
|
||||
"entities": "Объекты"
|
||||
}
|
||||
},
|
||||
"choose_label": {
|
||||
"title": "Ярлык для отбора объектов",
|
||||
"description": "Выберите ярлык, объекты с которым будут переданы в УДЯ/Марусю в виде устройств.\n\n Ярлыки на устройствах или зонах не поддерживаются. Ярлык можно создать непосредственно при редактировании объекта.\n\nЕсли устройство присутствует в УДЯ – соответствующий объект всегда должен иметь этот ярлык.\n\nУдаление устройств из УДЯ возможно **только** вручную, для удаления всех устройств [отвяжите навык](https://docs.yaha-cloud.ru/v1.0.x/platforms/yandex/#unlink).\n\nМаруся всегда отображает только объекты с выбранным ярлыком.",
|
||||
"data": {
|
||||
"label": "Ярлык"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"missing_external_url": "**Ошибка**\n\n**Использование прямого подключение невозможно**: не задан внешний URL-адрес сервера Home Assistant.\n\nДля исправления:\n* Включите расширенный режим в [настройках профиля](https://my.home-assistant.io/redirect/profile/)\n* Задайте URL-адрес сервера в Настройки > Система > [Сеть](https://my.home-assistant.io/redirect/network/) или в параметре [`external_url`](https://www.home-assistant.io/integrations/homeassistant/#external_url) в `configuration.yaml\n* Повторно откройте настройки интеграции"
|
||||
},
|
||||
"error": {
|
||||
"unknown": "{error}",
|
||||
"cannot_connect": "Не удалось подключиться, проверьте журнал сервера",
|
||||
"entities_not_selected": "Необходимо выбрать хотя бы один объект",
|
||||
"missing_config_entry": "Не найдено подходящих интеграций",
|
||||
"already_configured": "Для этого пользователя уже настроена интеграция {entry_title}",
|
||||
"manual_revoke_oauth_tokens": "Автоматическая отвязка навыков не поддерживается. Вы можете отвязать навыки через удаление Токенов обновления в настройках профиля на вкладке Безопасность"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"menu_options": {
|
||||
"expose_settings": "Объекты для передачи в УДЯ",
|
||||
"cloud_credentials": "Реквизиты для привязки",
|
||||
"context_user": "Пользователь в журналах",
|
||||
"skill_yandex_direct": "Параметры навыка",
|
||||
"skill_vk_direct": "Параметры приложения",
|
||||
"skill_yandex_cloud_plus": "Параметры навыка",
|
||||
"skill_vk_cloud_plus": "Параметры приложения",
|
||||
"maintenance": "Сервисное меню"
|
||||
}
|
||||
},
|
||||
"expose_settings": {
|
||||
"title": "Объекты для передачи в УДЯ",
|
||||
"description": "Нажмите **Подтвердить** для перехода к списку объектов или выбору ярлыка.",
|
||||
"data": {
|
||||
"filter_source": "Способ выбора объектов для передачи в УДЯ",
|
||||
"entry_aliases": "Учитывать альтернативные названия устройств и комнат"
|
||||
},
|
||||
"data_description": {
|
||||
"entry_aliases": "Отключите этот параметр, если используете альтернативные названия для функции Assist.\nСм. также: [особенности именования устройств и комнат](https://docs.yaha-cloud.ru/v1.0.x/quirks#naming)"
|
||||
}
|
||||
},
|
||||
"update_filter": {
|
||||
"title": "Объекты для передачи в УДЯ",
|
||||
"description": "Выберите интеграцию с которой будут скопированы объекты для передачи в УДЯ. На следующем шаге вы сможете изменить этот список.",
|
||||
"data": {
|
||||
"id": "Интеграция"
|
||||
}
|
||||
},
|
||||
"include_entities": {
|
||||
"title": "Объекты для передачи в УДЯ",
|
||||
"description": "Выберите объекты, которые будут переданы в УДЯ/Марусю в виде устройств.\n\nЕсли устройство присутствует в УДЯ – соответствующий объект всегда должен находиться в этом списке.\n\nУдаление устройств из УДЯ возможно **только** вручную, для удаления всех устройств [отвяжите навык](https://docs.yaha-cloud.ru/v1.0.x/platforms/yandex/#unlink).\n\nМаруся всегда отображает только выбранные объекты.\n\nНажмите **Подтвердить** для сохранения изменений.",
|
||||
"data": {
|
||||
"entities": "Объекты"
|
||||
}
|
||||
},
|
||||
"choose_label": {
|
||||
"title": "Ярлык для отбора объектов",
|
||||
"description": "Выберите ярлык, объекты с которым будут переданы в УДЯ/Марусю в виде устройств.\n\n Ярлыки на устройствах или зонах не поддерживаются. Ярлык можно создать непосредственно при редактировании объекта.\n\nЕсли устройство присутствует в УДЯ – соответствующий объект всегда должен иметь этот ярлык.\n\nУдаление устройств из УДЯ возможно **только** вручную, для удаления всех устройств [отвяжите навык](https://docs.yaha-cloud.ru/v1.0.x/platforms/yandex/#unlink).\n\nМаруся всегда отображает только объекты с выбранным ярлыком.\n\nНажмите **Подтвердить** для сохранения изменений.",
|
||||
"data": {
|
||||
"label": "Ярлык"
|
||||
}
|
||||
},
|
||||
"context_user": {
|
||||
"title": "Пользователь в журналах",
|
||||
"description": "Выберите пользователя, от имени которого будут управляться устройства (отображается в Журнале событий).\n\nСоздать пользователя можно в Настройки > Люди > Добавить персону (отметить Разрешить вход в систему).",
|
||||
"data": {
|
||||
"user_id": "Пользователь"
|
||||
}
|
||||
},
|
||||
"cloud_credentials": {
|
||||
"title": "Облачное подключение",
|
||||
"description": "Реквизиты для привязки Home Assistant к навыку {skill}:\n* **Одноразовый код**: `{otp}`\n\nили:\n* **ID**: `{id}`\n* **Пароль**: `{password}`\n\nДля добавления Home Assistant в Умный дом Яндекса:\n * Откройте приложение [Дом с Алисой](https://ya.cc/iot_app)\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Устройство умного дома\"\n* Найдите в списке и выберите производителя \"{skill}\"\n* Нажмите кнопку \"Привязать к Яндексу\", откроется страница авторизации\n* Выполните привязку используя реквизиты выше\n\nДля управления устройствами через Марусю:\n* Откройте приложение [Маруся](https://trk.mail.ru/c/u2dc49)\n* Нажмите иконку \"Домик\" в правом верхнем углу\n* Нажмите кнопку \"+\" в правом верхнем углу, выберите \"Подключить устройство\"\n* Найдите в списке и выберите бренд \"{skill}\"\n* Откроется страница авторизации\n* Выполните привязку используя реквизиты выше"
|
||||
},
|
||||
"skill_yandex_direct": {
|
||||
"title": "Параметры навыка",
|
||||
"description": "Параметры навыка можно изменить в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer)\n\nВкладка **Настройки**:\n* Backend: `{external_url}/api/yandex_smart_home`\n\nВкладка **Связка аккаунтов**:\n* Идентификатор приложения: `https://social.yandex.net/`\n* Секрет приложения: **`secret`**\n* URL авторизации: `{external_url}/auth/authorize`\n* URL для получения токена: `{external_url}/auth/token`\n* URL для обновления токена: `{external_url}/auth/token`\n\n**Важно!** При изменении идентификатора диалога или пользователя **обязательно** нажмите \"Обновить список устройств\" в приложении Дом с Алисой",
|
||||
"data": {
|
||||
"user_id": "Пользователь",
|
||||
"id": "Идентификатор диалога",
|
||||
"token": "Токен"
|
||||
},
|
||||
"data_description": {
|
||||
"user_id": "Пользователь Home Assistant, который будет использовать эту интеграцию\nПри связывании аккаунтов **обязательно** использовать его при входе\nСоздать пользователя можно в Настройки > Люди > Добавить персону (отметить Разрешить вход в систему)",
|
||||
"id": "С вкладки **Общие сведения** настроек вашего навыка (в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer))",
|
||||
"token": "Получите по [ссылке](https://oauth.yandex.ru/authorize?response_type=token&client_id=c473ca268cd749d3a8371351a8f2bcbd)\nУбедитесь, что вошли в Яндекс под аккаунтом владельца диалога"
|
||||
}
|
||||
},
|
||||
"skill_vk_direct": {
|
||||
"title": "Параметры приложения",
|
||||
"description": "Параметры приложения можно изменить на платформе [VK для разработчиков](https://platform.vk.com):\n\n* Адрес API: `{external_url}/api/yandex_smart_home/v1.0`\n* Адрес страницы авторизации: `{external_url}/auth/authorize`\n* Адрес для получения тоĸена: `{external_url}/auth/token`\n* Адрес для обновления токена: `{external_url}/auth/token`\n* Авторизационный client_id: `https://vc.go.mail.ru`\n* Авторизационный secret: `secret`\n* Протокол интеграции: `Яндекс`",
|
||||
"data": {
|
||||
"user_id": "Пользователь",
|
||||
"id": "ID приложения"
|
||||
},
|
||||
"data_description": {
|
||||
"user_id": "Пользователь Home Assistant, который будет использовать эту интеграцию\nПри связывании аккаунтов **обязательно** использовать его при входе\nСоздать пользователя можно в Настройки > Люди > Добавить персону (отметить Разрешить вход в систему)",
|
||||
"id": "Из настроек приложения на платформе [VK для разработчиков](https://platform.vk.com)"
|
||||
}
|
||||
},
|
||||
"skill_yandex_cloud_plus": {
|
||||
"title": "Параметры навыка",
|
||||
"description": "Параметры навыка можно изменить в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer)\n\nВкладка **Настройки**:\n* Backend: `{cloud_base_url}/api/yandex_smart_home`\n\nВкладка **Связка аккаунтов**:\n* Идентификатор приложения: `yandex_smart_home:{instance_id}`\n* Секрет приложения: **`secret`**\n* URL авторизации: `{cloud_base_url}/oauth/authorize`\n* URL для получения токена: `{cloud_base_url}/oauth/token`\n* URL для обновления токена: `{cloud_base_url}/oauth/token`\n\n**Важно!** При изменении идентификатора диалога **обязательно** нажмите \"Обновить список устройств\" в приложении Дом с Алисой",
|
||||
"data": {
|
||||
"name": "Название диалога",
|
||||
"id": "Идентификатор диалога",
|
||||
"token": "Токен"
|
||||
},
|
||||
"data_description": {
|
||||
"id": "С вкладки **Общие сведения** настроек вашего навыка (в консоли [Яндекс Диалоги](https://dialogs.yandex.ru/developer))",
|
||||
"token": "Получите по [ссылке](https://oauth.yandex.ru/authorize?response_type=token&client_id=c473ca268cd749d3a8371351a8f2bcbd)\nУбедитесь, что вошли в Яндекс под аккаунтом владельца диалога"
|
||||
}
|
||||
},
|
||||
"skill_vk_cloud_plus": {
|
||||
"title": "Параметры приложения",
|
||||
"description": "Параметры приложения можно изменить на платформе [VK для разработчиков](https://platform.vk.com):\n\n* Адрес API: `{cloud_base_url}/api/yandex_smart_home/v1.0`\n* Адрес страницы авторизации: `{cloud_base_url}/oauth/authorize`\n* Адрес для получения тоĸена: `{cloud_base_url}/oauth/token`\n* Адрес для обновления токена: `{cloud_base_url}/oauth/token`\n* Авторизационный client_id: `vk_smart_home:{instance_id}`\n* Авторизационный secret: `secret`\n* Протокол интеграции: `Яндекс`",
|
||||
"data": {
|
||||
"name": "Название приложения",
|
||||
"id": "ID приложения"
|
||||
},
|
||||
"data_description": {
|
||||
"id": "Из настроек приложения на платформе [VK для разработчиков](https://platform.vk.com)"
|
||||
}
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Сервисное меню",
|
||||
"description": "**Внимание!** Действия в этом разделе могут привести к невозможности управлять устройствами через УДЯ или к неправильной работе интеграции.",
|
||||
"data": {
|
||||
"revoke_oauth_tokens": "Отвязать навыки",
|
||||
"unlink_all_platforms": "Пометить навыки отвязанными",
|
||||
"reset_cloud_instance_connection_token": "Обновить токен подключения к Yaha Cloud",
|
||||
"transfer_entity_filter_from_yaml": "Перенести фильтр объектов из YAML"
|
||||
},
|
||||
"data_description": {
|
||||
"revoke_oauth_tokens": "Очищает данные аутентификации для всех привязанных навыков, что приводит к невозможности управления устройствами через УДЯ/Марусю. После выполнения этой операции необходимо вручную отвязать навык в УДЯ/Марусе (без удаления устройств) и привязать повторно.",
|
||||
"unlink_all_platforms": "Отключает отправку уведомлений о состоянии устройств по всем привязанным навыкам. Для возобновления отправки вручную обновите список устройств через УДЯ или зайдите в список устройств в приложение Маруся.",
|
||||
"reset_cloud_instance_connection_token": "Обновляет служебный токен для подключения к Yaha Cloud, не изменяет ID и пароль.",
|
||||
"transfer_entity_filter_from_yaml": "Переносит список объектов для передачи из YAML конфигурации (параметр `filter`) в настройки интеграции или проставляет этим объектам выбранный ярлык, подробнее в [документации](https://docs.yaha-cloud.ru/v1.0.x/config/filter/#migration-from-yaml)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_pressure_unit": {
|
||||
"title": "Устаревший параметр pressure_unit",
|
||||
"description": "Параметр `pressure_unit` (раздел `settings`) больше не поддерживается, удалите его из YAML конфигурации.\n\nТеперь компонент автоматически пытается сохранить единицы измерения при передаче значений датчиков из Home Assistant в УДЯ ([подробнее о конвертации значений](https://docs.yaha-cloud.ru/v1.0.x/devices/sensor/float/#unit-conversion))"
|
||||
},
|
||||
"deprecated_yaml_notifier": {
|
||||
"title": "Устаревшие настройки прямого подключения",
|
||||
"description": "Служба уведомлений о состоянии устройств (нотификатор) теперь настраивается через [интерфейс](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui) (раздел `Параметры навыка`).\n\nПараметры из YAML конфигурации были перенесены **автоматически**, удалите секцию **`notifier`** из YAML конфигурации.\n"
|
||||
},
|
||||
"deprecated_yaml_several_notifiers": {
|
||||
"title": "Устаревшие настройки прямого подключения",
|
||||
"description": "Служба уведомлений о состоянии устройств (нотификатор) теперь настраивается через [интерфейс](https://docs.yaha-cloud.ru/v1.0.x/config/getting-started/#gui) (раздел `Параметры навыка`).\n\nПеренесите настройки из секции `notifier` в настройки интеграции вручную, после этого удалите секцию `notifier` из YAML конфигурации.\n\nДля каждой записи в секции `notifier` потребуется создать отдельную интеграцию с прямым типом подключения."
|
||||
},
|
||||
"missing_integration": {
|
||||
"title": "Не найдена подходящая интеграция",
|
||||
"description": "Для входящего запроса от {platform} не найдена интеграция для пользователя {username}.\n\nДобавьте новую интеграцию Yandex Smart Home с прямым подключением и пользователем {username} в настройках навыка, или измените пользователя у существующей интеграции.\n\nЭта проблема закроется автоматически при следующем удачном запросе или перезапуске Home Assistant."
|
||||
},
|
||||
"missing_skill_data":{
|
||||
"title": "Требуется дополнительная настройка",
|
||||
"description": "В настройках интеграции {entry_title} требуется заполнить параметры навыка. Без этих данных интеграция не сможет передавать в УДЯ информацию об актуальном состоянии устройств."
|
||||
},
|
||||
"reconnecting_too_fast": {
|
||||
"title": "Частые переподключения",
|
||||
"description": "Интеграция {entry_title} слишком часто переподключается к облачному серверу. Это приводит к периодической невозможности управлять устройствами из УДЯ.\n\nСкорее всего одновременно запущено несколько Home Assistant, которые были развернуты из одной резервной копии."
|
||||
},
|
||||
"unexposed_entity_found_config_entry": {
|
||||
"title": "Не выбран один или несколько объектов для передачи",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "В УДЯ существуют устройства, объекты которых не выбраны в списке объектов для передачи в настройках интеграции {entry_title}.\n\nЭто может приводить к некорректному отображению состояний устройств в УДЯ и не влияет на управление устройствами.\n\nСпособы решения проблемы:\n1. Добавьте затронутые объекты в список для передачи в [настройках](https://docs.yaha-cloud.ru/v1.0.x/config/filter/#config-flow) интеграции или выберите \"Добавить автоматически\" и нажмите \"Подтвердить\" для автоматического добавления\n\n2. Удалите из УДЯ лишние устройства и перезагрузите интеграцию или Home Assistant\n\nЗатронутые объекты:\n{entities}",
|
||||
"data": {
|
||||
"include_entities": "Добавить автоматически все затронутые объекты в список для передачи"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"unexposed_entity_found_yaml": {
|
||||
"title": "Не выбран один или несколько объектов для передачи",
|
||||
"description": "В УДЯ существуют устройства, объекты которых не попадают под [фильтры](https://docs.yaha-cloud.ru/v1.0.x/config/filter/#yaml) в YAML конфигурации.\n\nЭто может приводить к некорректному отображению состояний устройств в УДЯ и не влияет на управление устройствами.\n\nСпособы решения проблемы:\n1. Добавьте затронутые объекты список `include_entities` в параметре `yandex_smart_home.filter` YAML конфигурации и перезагрузите её через Панель разработчика\n\n2. Удалите из УДЯ лишние устройства и перезагрузите интеграцию или Home Assistant\n\nЗатронутые объекты:\n{entities}"
|
||||
},
|
||||
"unexposed_entity_found_label": {
|
||||
"title": "Не выбран один или несколько объектов для передачи",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "В УДЯ существуют устройства, объекты которых не содержат ярлык \"{label}\".\n\nЭто может приводить к некорректному отображению состояний устройств в УДЯ и не влияет на управление устройствами.\n\nСпособы решения проблемы:\n1. Вручную добавьте ярлык \"{label}\" на затронутые объекты или выберите \"Добавить автоматически\" и нажмите \"Подтвердить\" для автоматического добавления ярлыка\n\n2. Удалите из УДЯ лишние устройства и перезагрузите интеграцию или Home Assistant\n\nЗатронутые объекты:\n{entities}",
|
||||
"data": {
|
||||
"add_label": "Добавить автоматически ярлык \"{label}\" на все затронутые объекты"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"connection_type": {
|
||||
"options": {
|
||||
"cloud": "Облачное",
|
||||
"cloud_plus": "Облачное Плюс",
|
||||
"direct": "Прямое"
|
||||
}
|
||||
},
|
||||
"filter_source": {
|
||||
"options": {
|
||||
"config_entry": "Через интерфейс (рекомендуется)",
|
||||
"get_from_config_entry": "Через интерфейс: скопировать с другой интеграции",
|
||||
"label": "Через ярлыки на объектах",
|
||||
"yaml": "Через YAML конфигурацию"
|
||||
}
|
||||
},
|
||||
"platform": {
|
||||
"options": {
|
||||
"yandex": "Дом с Алисой",
|
||||
"vk": "Маруся"
|
||||
}
|
||||
},
|
||||
"user_id": {
|
||||
"options": {
|
||||
"none": "Нет"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Unit conversion helpers."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
)
|
||||
from homeassistant.util.unit_conversion import (
|
||||
_IN_TO_M,
|
||||
_MERCURY_DENSITY,
|
||||
_MM_TO_M,
|
||||
_STANDARD_GRAVITY,
|
||||
BaseUnitConverter,
|
||||
)
|
||||
|
||||
from .schema import PressureUnit, TemperatureUnit
|
||||
|
||||
# EFEKTA iAQ3 (#570)
|
||||
UNIT_OF_MEASUREMENT_VOC_INDEX_POINT = "VOC Index points"
|
||||
|
||||
|
||||
class TVOCConcentrationConverter(BaseUnitConverter):
|
||||
"""Utility to convert TVOC concentration values."""
|
||||
|
||||
UNIT_CLASS = "tvoc"
|
||||
NORMALIZED_UNIT = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
VALID_UNITS = {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UNIT_OF_MEASUREMENT_VOC_INDEX_POINT,
|
||||
}
|
||||
|
||||
# average molecular weight of tVOC = 110 g/mol
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: 1 / 35.3146667215,
|
||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1 / 1000,
|
||||
CONCENTRATION_PARTS_PER_MILLION: 1 / 4496.29381184,
|
||||
CONCENTRATION_PARTS_PER_BILLION: 1 / 4.49629381184,
|
||||
UNIT_OF_MEASUREMENT_VOC_INDEX_POINT: 1,
|
||||
}
|
||||
|
||||
|
||||
class UnitOfTemperature(StrEnum):
|
||||
"""Temperature units."""
|
||||
|
||||
CELSIUS = "°C"
|
||||
FAHRENHEIT = "°F"
|
||||
KELVIN = "K"
|
||||
|
||||
@property
|
||||
def as_property_unit(self) -> TemperatureUnit:
|
||||
"""Return value as property unit."""
|
||||
match self:
|
||||
case self.CELSIUS:
|
||||
return TemperatureUnit.CELSIUS
|
||||
case self.KELVIN:
|
||||
return TemperatureUnit.KELVIN
|
||||
|
||||
raise ValueError
|
||||
|
||||
|
||||
class UnitOfPressure(StrEnum):
|
||||
"""Extended pressure units."""
|
||||
|
||||
ATM = "atm"
|
||||
PA = "Pa"
|
||||
HPA = "hPa"
|
||||
KPA = "kPa"
|
||||
BAR = "bar"
|
||||
CBAR = "cbar"
|
||||
MBAR = "mbar"
|
||||
MMHG = "mmHg"
|
||||
INHG = "inHg"
|
||||
PSI = "psi"
|
||||
|
||||
@property
|
||||
def as_property_unit(self) -> PressureUnit:
|
||||
"""Return value as property unit."""
|
||||
match self:
|
||||
case self.PA:
|
||||
return PressureUnit.PASCAL
|
||||
case self.MMHG:
|
||||
return PressureUnit.MMHG
|
||||
case self.ATM:
|
||||
return PressureUnit.ATM
|
||||
case self.BAR:
|
||||
return PressureUnit.BAR
|
||||
|
||||
raise ValueError
|
||||
|
||||
|
||||
class PressureConverter(BaseUnitConverter):
|
||||
"""Utility to convert pressure values."""
|
||||
|
||||
UNIT_CLASS = "pressure"
|
||||
NORMALIZED_UNIT = UnitOfPressure.PA
|
||||
_UNIT_CONVERSION: dict[str | None, float] = {
|
||||
UnitOfPressure.PA: 1,
|
||||
UnitOfPressure.HPA: 1 / 100,
|
||||
UnitOfPressure.KPA: 1 / 1000,
|
||||
UnitOfPressure.BAR: 1 / 100000,
|
||||
UnitOfPressure.CBAR: 1 / 1000,
|
||||
UnitOfPressure.MBAR: 1 / 100,
|
||||
UnitOfPressure.INHG: 1 / (_IN_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY),
|
||||
UnitOfPressure.PSI: 1 / 6894.757,
|
||||
UnitOfPressure.MMHG: 1 / (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY),
|
||||
UnitOfPressure.ATM: 1 / 101325,
|
||||
}
|
||||
VALID_UNITS = {
|
||||
UnitOfPressure.PA,
|
||||
UnitOfPressure.HPA,
|
||||
UnitOfPressure.KPA,
|
||||
UnitOfPressure.BAR,
|
||||
UnitOfPressure.CBAR,
|
||||
UnitOfPressure.MBAR,
|
||||
UnitOfPressure.INHG,
|
||||
UnitOfPressure.PSI,
|
||||
UnitOfPressure.MMHG,
|
||||
UnitOfPressure.ATM,
|
||||
}
|
||||
Reference in New Issue
Block a user