diff --git a/custom_components/yandex_smart_home/capability.py b/custom_components/yandex_smart_home/capability.py index 528f2086..d576c052 100644 --- a/custom_components/yandex_smart_home/capability.py +++ b/custom_components/yandex_smart_home/capability.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod import logging -from typing import Any, TypeVar +from typing import Any, Generic, TypeVar from homeassistant.const import ATTR_SUPPORTED_FEATURES from homeassistant.core import Context, HomeAssistant, State @@ -22,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) _CapabilityT = TypeVar("_CapabilityT", bound="AbstractCapability") + CAPABILITIES: list[type[AbstractCapability]] = [] @@ -31,11 +32,11 @@ def register_capability(capability: type[_CapabilityT]) -> type[_CapabilityT]: return capability -class AbstractCapability(ABC): +class AbstractCapability(Generic[CapabilityInstanceActionState], ABC): """Represents a device base capability.""" type: CapabilityType - instance: CapabilityInstance | None = None + instance: CapabilityInstance def __init__(self, hass: HomeAssistant, config: Config, state: State): """Initialize a capability for a state.""" @@ -81,11 +82,13 @@ def get_value(self) -> Any: def get_instance_state(self) -> CapabilityInstanceState | None: """Return a state for a device query request.""" - if (value := self.get_value()) is not None: + if (value := self.get_value()) is not None and self.instance: 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 @@ -96,7 +99,7 @@ async def set_instance_state( @property def _state_features(self) -> int: """Return features attribute for the state.""" - return self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + return int(self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)) class ActionOnlyCapability(AbstractCapability, ABC): diff --git a/custom_components/yandex_smart_home/capability_color.py b/custom_components/yandex_smart_home/capability_color.py index 877e3672..6356adad 100644 --- a/custom_components/yandex_smart_home/capability_color.py +++ b/custom_components/yandex_smart_home/capability_color.py @@ -1,6 +1,7 @@ """Implement the Yandex Smart Home color_setting capability.""" from functools import cached_property import logging +from typing import Any from homeassistant.components import light from homeassistant.const import ATTR_ENTITY_ID @@ -30,13 +31,14 @@ @register_capability -class ColorSettingCapability(AbstractCapability): +class ColorSettingCapability(AbstractCapability[ColorSettingCapabilityInstanceActionState]): """Root capability to discover another light device capabilities. https://yandex.ru/dev/dialogs/smart-home/doc/concepts/color_setting.html """ type = CapabilityType.COLOR_SETTING + instance = ColorSettingCapabilityInstance.BASE def __init__(self, hass: HomeAssistant, config: Config, state: State): """Initialize a capability for a state.""" @@ -60,7 +62,7 @@ def parameters(self) -> ColorSettingCapabilityParameters | None: """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, + temperature_k=self._temperature.parameters.temperature_k if self._temperature.parameters else None, color_scene=self._color_scene.parameters.color_scene if self._color_scene.supported else None, ) @@ -79,7 +81,7 @@ def _capabilities(self) -> list[AbstractCapability]: @register_capability -class RGBColorCapability(AbstractCapability): +class RGBColorCapability(AbstractCapability[RGBInstanceActionState]): """Capability to control color of a light device.""" type = CapabilityType.COLOR_SETTING @@ -120,7 +122,7 @@ def get_value(self) -> int | None: if self.state.attributes.get(light.ATTR_COLOR_MODE) == light.COLOR_MODE_COLOR_TEMP: return None - rgb_color: tuple[int, int, int] = self.state.attributes.get(light.ATTR_RGB_COLOR) + rgb_color = self.state.attributes.get(light.ATTR_RGB_COLOR) if rgb_color is None: hs_color = self.state.attributes.get(light.ATTR_HS_COLOR) if hs_color is not None: @@ -136,6 +138,8 @@ def get_value(self) -> int | None: return self._converter.get_yandex_color(RGBColor(*rgb_color)) + return None + async def set_instance_state(self, context: Context, state: RGBInstanceActionState) -> None: """Change the capability state.""" await self._hass.services.async_call( @@ -166,7 +170,7 @@ def _converter(self) -> ColorConverter: @register_capability -class ColorTemperatureCapability(AbstractCapability): +class ColorTemperatureCapability(AbstractCapability[TemperatureKInstanceActionState]): """Capability to control color temperature of a light device.""" type = CapabilityType.COLOR_SETTING @@ -191,8 +195,11 @@ def supported(self) -> bool: return False @property - def parameters(self) -> ColorSettingCapabilityParameters: + def parameters(self) -> ColorSettingCapabilityParameters | None: """Return parameters for a devices list request.""" + if not self.supported: + return None + supported_color_modes = set(self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])) if self._state_features & light.SUPPORT_COLOR_TEMP or light.color_temp_supported(supported_color_modes): @@ -211,6 +218,8 @@ def parameters(self) -> ColorSettingCapabilityParameters: temperature_k=CapabilityParameterTemperatureK(min=min_temp, max=max_temp) ) + return None # pragma: no cover + def get_description(self) -> None: """Return a description for a device list request. Capability with an empty description isn't discoverable.""" return None @@ -247,10 +256,12 @@ def get_value(self) -> int | None: return None + return None + async def set_instance_state(self, context: Context, state: TemperatureKInstanceActionState) -> None: """Change the capability state.""" supported_color_modes = set(self.state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, [])) - service_data = {} + service_data: dict[str, Any] = {} if self._state_features & light.SUPPORT_COLOR_TEMP or light.color_temp_supported(supported_color_modes): service_data[light.ATTR_KELVIN] = self._converter.get_ha_color_temperature(state.value) @@ -296,7 +307,7 @@ def _converter(self) -> ColorTemperatureConverter: @register_capability -class ColorSceneCapability(AbstractCapability): +class ColorSceneCapability(AbstractCapability[SceneInstanceActionState]): """Capability to control effect of a light device.""" type = CapabilityType.COLOR_SETTING @@ -347,6 +358,8 @@ def get_value(self) -> ColorScene | None: if effect := self.state.attributes.get(light.ATTR_EFFECT): return self.get_scene_by_effect(effect) + return None + async def set_instance_state(self, context: Context, state: SceneInstanceActionState) -> None: """Change the capability state.""" await self._hass.services.async_call( @@ -393,9 +406,13 @@ def get_scene_by_effect(self, effect: str) -> ColorScene | None: if effect.lower() in effects: return scene + return None + def get_effect_by_scene(self, scene: ColorScene) -> str | None: """Return HA light effect for Yandex scene.""" for effect in self.scenes_map.get(scene, {}): for supported_effect in self.state.attributes.get(light.ATTR_EFFECT_LIST, []): if str(supported_effect).lower() == effect: - return supported_effect + return str(supported_effect) + + return None diff --git a/custom_components/yandex_smart_home/capability_custom.py b/custom_components/yandex_smart_home/capability_custom.py index 383cf7d4..97caeaef 100644 --- a/custom_components/yandex_smart_home/capability_custom.py +++ b/custom_components/yandex_smart_home/capability_custom.py @@ -21,6 +21,7 @@ CONF_ENTITY_RANGE_MIN, CONF_ENTITY_RANGE_PRECISION, ERR_DEVICE_UNREACHABLE, + ERR_INTERNAL_ERROR, ERR_NOT_SUPPORTED_IN_CURRENT_MODE, ) from .error import SmartHomeError @@ -68,14 +69,16 @@ def get_value(self) -> Any: entity_state = self.state if self._state_entity_id: - entity_state = self._hass.states.get(self._state_entity_id) - if not entity_state: + state_by_entity_id = self._hass.states.get(self._state_entity_id) + if not state_by_entity_id: raise SmartHomeError( ERR_DEVICE_UNREACHABLE, f"Entity {self._state_entity_id} not found for " f"{self.instance} instance of {self.state.entity_id}", ) + entity_state = state_by_entity_id + if self._state_value_attribute: value = entity_state.attributes.get(self._state_value_attribute) else: @@ -195,6 +198,9 @@ async def set_instance_state(self, context: Context, state: RangeCapabilityInsta value = self._get_absolute_value(state.value) + if not config: + raise SmartHomeError(ERR_INTERNAL_ERROR, "Missing capability service") + await async_call_from_config( self._hass, config, diff --git a/custom_components/yandex_smart_home/capability_mode.py b/custom_components/yandex_smart_home/capability_mode.py index ccc3a983..0ea972ba 100644 --- a/custom_components/yandex_smart_home/capability_mode.py +++ b/custom_components/yandex_smart_home/capability_mode.py @@ -23,13 +23,14 @@ _LOGGER = logging.getLogger(__name__) -class ModeCapability(AbstractCapability, ABC): +class ModeCapability(AbstractCapability[ModeCapabilityInstanceActionState], ABC): """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.MODE + instance: ModeCapabilityInstance _modes_map_default: dict[ModeCapabilityMode, list[str]] = {} _modes_map_index_fallback: dict[int, ModeCapabilityMode] = {} @@ -65,7 +66,10 @@ def supported_yandex_modes(self) -> list[ModeCapabilityMode]: @property def supported_ha_modes(self) -> list[str]: """Returns list of supported HA modes.""" - return self.state.attributes.get(self.modes_list_attribute, []) or [] + if self.modes_list_attribute: + return self.state.attributes.get(self.modes_list_attribute, []) or [] + + return [] # pragma: no cover @property def modes_map(self) -> dict[ModeCapabilityMode, list[str]]: @@ -88,8 +92,11 @@ def state_value_attribute(self) -> str | None: """Return HA attribute for state of the entity.""" return None - def get_yandex_mode_by_ha_mode(self, ha_mode: str | None, hide_warnings=False) -> ModeCapabilityMode | None: + def get_yandex_mode_by_ha_mode(self, ha_mode: str | None, hide_warnings: bool = False) -> ModeCapabilityMode | None: """Return Yandex mode by HA mode.""" + if ha_mode is None: + return None + rv = None for yandex_mode, names in self.modes_map.items(): lower_names = [str(n).lower() for n in names] @@ -176,7 +183,7 @@ def supported(self) -> bool: return False @property - def modes_list_attribute(self) -> str | None: + def modes_list_attribute(self) -> str: """Return HA attribute contains modes list for the entity.""" return climate.ATTR_HVAC_MODES @@ -216,12 +223,12 @@ def supported(self) -> bool: return False @property - def modes_list_attribute(self) -> str | None: + def modes_list_attribute(self) -> str: """Return HA attribute contains modes list for the entity.""" return climate.ATTR_SWING_MODES @property - def state_value_attribute(self) -> str | None: + def state_value_attribute(self) -> str: """Return HA attribute for state of the entity.""" return climate.ATTR_SWING_MODE @@ -313,12 +320,12 @@ def supported(self) -> bool: return False @property - def modes_list_attribute(self) -> str | None: + def modes_list_attribute(self) -> str: """Return HA attribute contains modes list for the entity.""" return humidifier.ATTR_AVAILABLE_MODES @property - def state_value_attribute(self) -> str | None: + def state_value_attribute(self) -> str: """Return HA attribute for state of the entity.""" return humidifier.ATTR_MODE @@ -387,12 +394,12 @@ def supported(self) -> bool: return False @property - def modes_list_attribute(self) -> str | None: + def modes_list_attribute(self) -> str: """Return HA attribute contains modes list for the entity.""" return fan.ATTR_PRESET_MODES @property - def state_value_attribute(self) -> str | None: + def state_value_attribute(self) -> str: """Return HA attribute for state of the entity.""" return fan.ATTR_PRESET_MODE @@ -414,7 +421,7 @@ async def set_instance_state(self, context: Context, state: ModeCapabilityInstan class InputSourceCapability(ModeCapability): """Capability to control the input source of a media player device.""" - instance = const.MODE_INSTANCE_INPUT_SOURCE + instance = ModeCapabilityInstance.INPUT_SOURCE _modes_map_index_fallback = { 0: ModeCapabilityMode.ONE, @@ -453,16 +460,16 @@ def supported_ha_modes(self) -> list[str]: return self._cache.get_attr_value(self.state.entity_id, self.modes_list_attribute) or [] @property - def modes_list_attribute(self) -> str | None: + def modes_list_attribute(self) -> str: """Return HA attribute contains modes list for the entity.""" return media_player.ATTR_INPUT_SOURCE_LIST @property - def state_value_attribute(self) -> str | None: + def state_value_attribute(self) -> str: """Return HA attribute for state of the entity.""" return media_player.ATTR_INPUT_SOURCE - def get_yandex_mode_by_ha_mode(self, ha_mode: str | None, hide_warnings=False) -> ModeCapabilityMode | None: + def get_yandex_mode_by_ha_mode(self, ha_mode: str | None, hide_warnings: bool = False) -> ModeCapabilityMode | None: """Return Yandex mode by HA mode.""" return super().get_yandex_mode_by_ha_mode(ha_mode, hide_warnings=True) @@ -540,12 +547,12 @@ def supported(self) -> bool: return False @property - def modes_list_attribute(self) -> str | None: + def modes_list_attribute(self) -> str: """Return HA attribute contains modes list for the entity.""" return climate.ATTR_FAN_MODES @property - def state_value_attribute(self) -> str | None: + def state_value_attribute(self) -> str: """Return HA attribute for state of the entity.""" return climate.ATTR_FAN_MODE @@ -631,12 +638,12 @@ def supported(self) -> bool: return False @property - def modes_list_attribute(self) -> str | None: + def modes_list_attribute(self) -> str: """Return HA attribute contains modes list for the entity.""" return fan.ATTR_PRESET_MODES @property - def state_value_attribute(self) -> str | None: + def state_value_attribute(self) -> str: """Return HA attribute for state of the entity.""" return fan.ATTR_PRESET_MODE @@ -693,7 +700,7 @@ def supported_ha_modes(self) -> list[str]: if speed_count >= 7: modes.append(ModeCapabilityMode.TURBO) - return modes + return [str(s) for s in modes] @property def supported_yandex_modes(self) -> list[ModeCapabilityMode]: @@ -701,7 +708,7 @@ def supported_yandex_modes(self) -> list[ModeCapabilityMode]: return [ModeCapabilityMode(m) for m in self.supported_ha_modes] @property - def modes_list_attribute(self) -> str | None: + def modes_list_attribute(self) -> None: """Return HA attribute contains modes list for the entity.""" return None @@ -784,12 +791,12 @@ def supported(self) -> bool: return False @property - def modes_list_attribute(self) -> str | None: + def modes_list_attribute(self) -> str: """Return HA attribute contains modes list for the entity.""" return vacuum.ATTR_FAN_SPEED_LIST @property - def state_value_attribute(self) -> str | None: + def state_value_attribute(self) -> str: """Return HA attribute for state of the entity.""" return vacuum.ATTR_FAN_SPEED diff --git a/custom_components/yandex_smart_home/capability_onoff.py b/custom_components/yandex_smart_home/capability_onoff.py index 4b4b76ce..f8d08bce 100644 --- a/custom_components/yandex_smart_home/capability_onoff.py +++ b/custom_components/yandex_smart_home/capability_onoff.py @@ -56,7 +56,7 @@ _LOGGER = logging.getLogger(__name__) -class OnOffCapability(AbstractCapability, ABC): +class OnOffCapability(AbstractCapability[OnOffCapabilityInstanceActionState], ABC): """Base class for capabilitity to turn on and off a device. https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/on_off-docpage/ @@ -84,6 +84,8 @@ def parameters(self) -> OnOffCapabilityParameters | None: 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 @@ -142,7 +144,7 @@ class OnOffCapabilityAutomation(OnOffCapability): @property def supported(self) -> bool: """Test if the capability is supported for its state.""" - return self.state.domain == automation.DOMAIN + return bool(self.state.domain == automation.DOMAIN) def get_value(self) -> bool | None: """Return the current capability value.""" @@ -393,7 +395,7 @@ async def _set_instance_state(self, context: Context, state: OnOffCapabilityInst if state.value: service = SERVICE_TURN_ON - hvac_modes = self.state.attributes.get(climate.ATTR_HVAC_MODES) + hvac_modes = self.state.attributes.get(climate.ATTR_HVAC_MODES, []) for mode in (climate.HVACMode.HEAT_COOL, climate.HVACMode.AUTO): if mode not in hvac_modes: continue @@ -443,7 +445,7 @@ async def _set_instance_state(self, context: Context, state: OnOffCapabilityInst # turn_on/turn_off is not supported pass - operation_list = self.state.attributes.get(water_heater.ATTR_OPERATION_LIST) + operation_list = self.state.attributes.get(water_heater.ATTR_OPERATION_LIST, []) if state.value: mode = self._get_water_heater_operation(STATE_ON, operation_list) diff --git a/custom_components/yandex_smart_home/capability_range.py b/custom_components/yandex_smart_home/capability_range.py index e24ddd86..c8c4944b 100644 --- a/custom_components/yandex_smart_home/capability_range.py +++ b/custom_components/yandex_smart_home/capability_range.py @@ -47,7 +47,7 @@ _LOGGER = logging.getLogger(__name__) -class RangeCapability(AbstractCapability, ABC): +class RangeCapability(AbstractCapability[RangeCapabilityInstanceActionState], ABC): """Base class for capabilities with range functionality like volume or brightness. https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/range-docpage/ @@ -151,6 +151,8 @@ def _convert_to_float(self, value: Any, strict: bool = True) -> float | None: f"Unsupported value {value!r} for instance {self.instance} of {self.state.entity_id}", ) + return None + @register_capability class CoverPositionCapability(RangeCapability): @@ -161,7 +163,7 @@ class CoverPositionCapability(RangeCapability): @property def supported(self) -> bool: """Test if the capability is supported for its state.""" - return self.state.domain == cover.DOMAIN and self._state_features & cover.CoverEntityFeature.SET_POSITION + return self.state.domain == cover.DOMAIN and bool(self._state_features & cover.CoverEntityFeature.SET_POSITION) @property def support_random_access(self) -> bool: @@ -201,9 +203,8 @@ class TemperatureCapabilityWaterHeater(TemperatureCapability): @property def supported(self) -> bool: """Test if the capability is supported for its state.""" - return ( - self.state.domain == water_heater.DOMAIN - and self._state_features & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE + return self.state.domain == water_heater.DOMAIN and bool( + self._state_features & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE ) async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: @@ -224,8 +225,8 @@ def _get_value(self) -> float | None: def _default_range(self) -> RangeCapabilityRange: """Return a default supporting range. Can be overrided by user.""" return RangeCapabilityRange( - min=self.state.attributes.get(water_heater.ATTR_MIN_TEMP), - max=self.state.attributes.get(water_heater.ATTR_MAX_TEMP), + 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, ) @@ -237,9 +238,8 @@ class TemperatureCapabilityClimate(TemperatureCapability): @property def supported(self) -> bool: """Test if the capability is supported for its state.""" - return ( - self.state.domain == climate.DOMAIN - and self._state_features & climate.ClimateEntityFeature.TARGET_TEMPERATURE + return self.state.domain == climate.DOMAIN and bool( + self._state_features & climate.ClimateEntityFeature.TARGET_TEMPERATURE ) async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None: @@ -260,8 +260,8 @@ def _get_value(self) -> float | None: def _default_range(self) -> RangeCapabilityRange: """Return a default supporting range. Can be overrided by user.""" return RangeCapabilityRange( - min=self.state.attributes.get(climate.ATTR_MIN_TEMP), - max=self.state.attributes.get(climate.ATTR_MAX_TEMP), + 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), ) @@ -382,6 +382,8 @@ def _get_value(self) -> float | None: if (brightness := self._convert_to_float(self.state.attributes.get(light.ATTR_BRIGHTNESS))) is not None: return int(100 * (brightness / 255)) + return None + @property def _default_range(self) -> RangeCapabilityRange: """Return a default supporting range. Can be overrided by user.""" @@ -461,6 +463,8 @@ async def set_instance_state(self, context: Context, state: RangeCapabilityInsta context=context, ) + return None + def _get_value(self) -> float | None: """Return the current capability value (unguarded).""" if ( @@ -468,6 +472,8 @@ def _get_value(self) -> float | None: ) is not None: return int(level * 100) + return None + @register_capability class ChannelCapability(RangeCapability): @@ -567,6 +573,8 @@ async def set_instance_state(self, context: Context, state: RangeCapabilityInsta 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) @@ -574,6 +582,8 @@ def _get_value(self) -> float | None: if media_content_type == media_player.const.MEDIA_TYPE_CHANNEL: return self._convert_to_float(self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_ID), strict=False) + return None + @property def _default_range(self) -> RangeCapabilityRange: """Return a default supporting range. Can be overrided by user.""" diff --git a/custom_components/yandex_smart_home/capability_toggle.py b/custom_components/yandex_smart_home/capability_toggle.py index b24ad38a..ddf95787 100644 --- a/custom_components/yandex_smart_home/capability_toggle.py +++ b/custom_components/yandex_smart_home/capability_toggle.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) -class ToggleCapability(AbstractCapability, ABC): +class ToggleCapability(AbstractCapability[ToggleCapabilityInstanceActionState], ABC): """Base class for capabilities with toggle functions like mute or pause. https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/toggle-docpage/ @@ -117,7 +117,7 @@ class PauseCapabilityCover(ActionOnlyCapability, ToggleCapability): @property def supported(self) -> bool: """Test if the capability is supported for its state.""" - return self.state.domain == cover.DOMAIN and self._state_features & cover.CoverEntityFeature.STOP + return self.state.domain == cover.DOMAIN and bool(self._state_features & cover.CoverEntityFeature.STOP) async def set_instance_state(self, context: Context, state: ToggleCapabilityInstanceActionState) -> None: """Change the capability state.""" @@ -139,7 +139,7 @@ class PauseCapabilityVacuum(ToggleCapability): @property def supported(self) -> bool: """Test if the capability is supported for its state.""" - return self.state.domain == vacuum.DOMAIN and self._state_features & vacuum.VacuumEntityFeature.PAUSE + return self.state.domain == vacuum.DOMAIN and bool(self._state_features & vacuum.VacuumEntityFeature.PAUSE) def get_value(self) -> bool: """Return the current capability value.""" @@ -166,7 +166,7 @@ class OscillationCapability(ToggleCapability): @property def supported(self) -> bool: """Test if the capability is supported for its state.""" - return self.state.domain == fan.DOMAIN and self._state_features & fan.FanEntityFeature.OSCILLATE + return self.state.domain == fan.DOMAIN and bool(self._state_features & fan.FanEntityFeature.OSCILLATE) def get_value(self) -> bool: """Return the current capability value.""" diff --git a/custom_components/yandex_smart_home/capability_video.py b/custom_components/yandex_smart_home/capability_video.py index dc9d38a2..0a224548 100644 --- a/custom_components/yandex_smart_home/capability_video.py +++ b/custom_components/yandex_smart_home/capability_video.py @@ -33,7 +33,7 @@ class VideoStreamCapability(ActionOnlyCapability): @property def supported(self) -> bool: """Test if the capability is supported for its state.""" - return self.state.domain == camera.DOMAIN and self._state_features & camera.CameraEntityFeature.STREAM + return self.state.domain == camera.DOMAIN and bool(self._state_features & camera.CameraEntityFeature.STREAM) @property def parameters(self) -> VideoStreamCapabilityParameters: diff --git a/custom_components/yandex_smart_home/color.py b/custom_components/yandex_smart_home/color.py index a0976aa4..758c6bbb 100644 --- a/custom_components/yandex_smart_home/color.py +++ b/custom_components/yandex_smart_home/color.py @@ -196,7 +196,7 @@ def get_yandex_color_temperature(self, ha_color_temperature: int) -> int: return self._ha_mapping.get(color_temperature, color_temperature) @property - def supported_range(self) -> (int, int): + def supported_range(self) -> tuple[int, int]: """Return temperature range supported for the state.""" return min(self._yandex_mapping), max(self._yandex_mapping) @@ -213,6 +213,8 @@ def _first_available_temperature_step(self) -> int | None: 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.""" @@ -221,9 +223,12 @@ def _last_available_temperature_step(self) -> int | None: try: return self._temperature_steps[idx + 1] except IndexError: - return None + pass + + return None - def _map_values(self, yandex_value: int, ha_value: int): + 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 diff --git a/custom_components/yandex_smart_home/error.py b/custom_components/yandex_smart_home/error.py index 20ef0e57..28b35093 100644 --- a/custom_components/yandex_smart_home/error.py +++ b/custom_components/yandex_smart_home/error.py @@ -7,7 +7,7 @@ class SmartHomeError(Exception): https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/response-codes-docpage/ """ - def __init__(self, code, msg): + def __init__(self, code: str, msg: str): super().__init__(msg) self.code = code self.message = msg @@ -16,5 +16,5 @@ def __init__(self, code, msg): class SmartHomeUserError(Exception): """Error producted by user's template, no logging""" - def __init__(self, code): + def __init__(self, code: str): self.code = code diff --git a/custom_components/yandex_smart_home/helpers.py b/custom_components/yandex_smart_home/helpers.py index dc025022..0f03aa36 100644 --- a/custom_components/yandex_smart_home/helpers.py +++ b/custom_components/yandex_smart_home/helpers.py @@ -17,6 +17,8 @@ class Config: """Hold the configuration for Yandex Smart Home.""" + cache: CacheStore + def __init__( self, hass: HomeAssistant, @@ -30,7 +32,6 @@ def __init__( self._options = entry.options self._entity_filter = entity_filter - self.cache: CacheStore | None = None self.entity_config = entity_config or {} async def async_init(self): diff --git a/custom_components/yandex_smart_home/schema/__init__.py b/custom_components/yandex_smart_home/schema/__init__.py index 4f2ce361..ae0521ea 100644 --- a/custom_components/yandex_smart_home/schema/__init__.py +++ b/custom_components/yandex_smart_home/schema/__init__.py @@ -1,9 +1,9 @@ """Yandex Smart Home API schemas.""" -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 .request import * +from .capability import * # noqa: F403 +from .capability_color import * # noqa: F403 +from .capability_mode import * # noqa: F403 +from .capability_onoff import * # noqa: F403 +from .capability_range import * # noqa: F403 +from .capability_toggle import * # noqa: F403 +from .capability_video import * # noqa: F403 +from .request import * # noqa: F403 diff --git a/custom_components/yandex_smart_home/schema/capability.py b/custom_components/yandex_smart_home/schema/capability.py index e9821041..c0990ebe 100644 --- a/custom_components/yandex_smart_home/schema/capability.py +++ b/custom_components/yandex_smart_home/schema/capability.py @@ -1,5 +1,5 @@ from enum import StrEnum -from typing import Annotated, Any, Literal, Union +from typing import Annotated, Any, Literal, TypeVar, Union from pydantic import BaseModel, Field @@ -7,8 +7,11 @@ ColorSettingCapabilityInstance, ColorSettingCapabilityInstanceActionState, ColorSettingCapabilityParameters, + RGBInstanceActionState, + SceneInstanceActionState, + TemperatureKInstanceActionState, ) -from .capability_mode import ModeCapabilityInstance, ModeCapabilityInstanceActionState, ModeCapabilityMode +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 @@ -32,6 +35,7 @@ class CapabilityType(StrEnum): CapabilityParameters = ( OnOffCapabilityParameters | ColorSettingCapabilityParameters + | ModeCapabilityParameters | RangeCapabilityParameters | ToggleCapabilityParameters | VideoStreamCapabilityParameters @@ -67,13 +71,17 @@ class CapabilityInstanceState(BaseModel): CapabilityInstanceActionResultValue = GetStreamInstanceActionResultValue | None -CapabilityInstanceActionState = ( - OnOffCapabilityInstanceActionState - | ColorSettingCapabilityInstanceActionState - | ModeCapabilityInstanceActionState - | RangeCapabilityInstanceActionState - | ToggleCapabilityInstanceActionState - | GetStreamInstanceActionState +CapabilityInstanceActionState = TypeVar( + "CapabilityInstanceActionState", + OnOffCapabilityInstanceActionState, + ColorSettingCapabilityInstanceActionState, + RGBInstanceActionState, + TemperatureKInstanceActionState, + SceneInstanceActionState, + ModeCapabilityInstanceActionState, + RangeCapabilityInstanceActionState, + ToggleCapabilityInstanceActionState, + GetStreamInstanceActionState, ) """New capability state in device action request.""" diff --git a/custom_components/yandex_smart_home/schema/capability_color.py b/custom_components/yandex_smart_home/schema/capability_color.py index 3d3f530e..54cf0ce2 100644 --- a/custom_components/yandex_smart_home/schema/capability_color.py +++ b/custom_components/yandex_smart_home/schema/capability_color.py @@ -10,6 +10,7 @@ class ColorSettingCapabilityInstance(StrEnum): + BASE = "base" RGB = "rgb" HSV = "hsv" TEMPERATURE_K = "temperature_k" diff --git a/custom_components/yandex_smart_home/schema/capability_range.py b/custom_components/yandex_smart_home/schema/capability_range.py index 44c9dfeb..797f62fe 100644 --- a/custom_components/yandex_smart_home/schema/capability_range.py +++ b/custom_components/yandex_smart_home/schema/capability_range.py @@ -29,7 +29,7 @@ class RangeCapabilityRange(BaseModel): max: float precision: float - def __str__(self): + def __str__(self) -> str: return f"[{self.min}, {self.max}]" diff --git a/pyproject.toml b/pyproject.toml index d5f8882b..a7518c37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,9 @@ line-length = 120 [tool.ruff] line-length = 120 +[tool.ruff.per-file-ignores] +"tests/test_schema.py" = ["F403", "F405"] + [tool.isort] profile = "black" line_length = 120 diff --git a/tests/test_capability.py b/tests/test_capability.py index d421d01c..98880cd8 100644 --- a/tests/test_capability.py +++ b/tests/test_capability.py @@ -282,7 +282,7 @@ async def test_capability_demo_platform(hass): assert entity.yandex_device_type == "devices.types.light" capabilities = list((c.type, c.instance) for c in entity.capabilities()) assert capabilities == [ - ("devices.capabilities.color_setting", None), + ("devices.capabilities.color_setting", "base"), ("devices.capabilities.color_setting", "rgb"), ("devices.capabilities.color_setting", "temperature_k"), ("devices.capabilities.color_setting", "scene"), @@ -295,7 +295,7 @@ async def test_capability_demo_platform(hass): assert entity.yandex_device_type == "devices.types.light" capabilities = list((c.type, c.instance) for c in entity.capabilities()) assert capabilities == [ - ("devices.capabilities.color_setting", None), + ("devices.capabilities.color_setting", "base"), ("devices.capabilities.color_setting", "rgb"), ("devices.capabilities.color_setting", "temperature_k"), ("devices.capabilities.range", "brightness"), @@ -307,7 +307,7 @@ async def test_capability_demo_platform(hass): assert entity.yandex_device_type == "devices.types.light" capabilities = list((c.type, c.instance) for c in entity.capabilities()) assert capabilities == [ - ("devices.capabilities.color_setting", None), + ("devices.capabilities.color_setting", "base"), ("devices.capabilities.color_setting", "rgb"), ("devices.capabilities.color_setting", "temperature_k"), ("devices.capabilities.range", "brightness"), @@ -319,7 +319,7 @@ async def test_capability_demo_platform(hass): assert entity.yandex_device_type == "devices.types.light" capabilities = list((c.type, c.instance) for c in entity.capabilities()) assert capabilities == [ - ("devices.capabilities.color_setting", None), + ("devices.capabilities.color_setting", "base"), ("devices.capabilities.color_setting", "rgb"), ("devices.capabilities.color_setting", "temperature_k"), ("devices.capabilities.range", "brightness"), @@ -331,7 +331,7 @@ async def test_capability_demo_platform(hass): assert entity.yandex_device_type == "devices.types.light" capabilities = list((c.type, c.instance) for c in entity.capabilities()) assert capabilities == [ - ("devices.capabilities.color_setting", None), + ("devices.capabilities.color_setting", "base"), ("devices.capabilities.color_setting", "rgb"), ("devices.capabilities.range", "brightness"), ("devices.capabilities.on_off", "on"), @@ -342,7 +342,7 @@ async def test_capability_demo_platform(hass): assert entity.yandex_device_type == "devices.types.light" capabilities = list((c.type, c.instance) for c in entity.capabilities()) assert capabilities == [ - ("devices.capabilities.color_setting", None), + ("devices.capabilities.color_setting", "base"), ("devices.capabilities.color_setting", "rgb"), ("devices.capabilities.color_setting", "temperature_k"), ("devices.capabilities.range", "brightness"), diff --git a/tests/test_capability_color.py b/tests/test_capability_color.py index 4092b1a0..df42ce52 100644 --- a/tests/test_capability_color.py +++ b/tests/test_capability_color.py @@ -30,7 +30,10 @@ def _get_color_setting_capability(hass: HomeAssistant, config: Config, state: State) -> ColorSettingCapability: return cast( - ColorSettingCapability, get_exact_one_capability(hass, config, state, CapabilityType.COLOR_SETTING, None) + ColorSettingCapability, + get_exact_one_capability( + hass, config, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.BASE + ), ) @@ -76,7 +79,7 @@ async def test_capability_color_setting(hass): async def test_capability_color_setting_rgb(hass, color_modes, features): state = State("light.test", STATE_OFF) assert_no_capabilities(hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.RGB) - assert_no_capabilities(hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, None) + assert_no_capabilities(hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.BASE) state = State( "light.test", STATE_OFF, {ATTR_SUPPORTED_FEATURES: features, light.ATTR_SUPPORTED_COLOR_MODES: color_modes} @@ -85,7 +88,9 @@ async def test_capability_color_setting_rgb(hass, color_modes, features): assert_no_capabilities( hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.RGB ) - assert_no_capabilities(hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, None) + assert_no_capabilities( + hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.BASE + ) return cap_rgb = cast( @@ -324,7 +329,7 @@ async def test_capability_color_setting_temperature_k(hass, attributes, temp_ran assert_no_capabilities( hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.TEMPERATURE_K ) - assert_no_capabilities(hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, None) + assert_no_capabilities(hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.BASE) state = State("light.test", STATE_OFF, attributes) cap_temp = cast( @@ -801,7 +806,7 @@ async def test_capability_color_setting_scene(hass): assert_no_capabilities( hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.SCENE ) - assert_no_capabilities(hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, None) + assert_no_capabilities(hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.BASE) state = State( "light.test", @@ -811,7 +816,7 @@ async def test_capability_color_setting_scene(hass): assert_no_capabilities( hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.SCENE ) - assert_no_capabilities(hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, None) + assert_no_capabilities(hass, BASIC_CONFIG, state, CapabilityType.COLOR_SETTING, ColorSettingCapabilityInstance.BASE) state = State( "light.test", diff --git a/tests/test_capability_custom.py b/tests/test_capability_custom.py index 60314af1..dbce9684 100644 --- a/tests/test_capability_custom.py +++ b/tests/test_capability_custom.py @@ -433,3 +433,27 @@ async def test_capability_custom_range_only_relative(hass): assert len(calls) == 2 assert calls[0].data == {"entity_id": "input_number.test", "value": "value: -3"} assert calls[1].data == {"entity_id": "input_number.test", "value": "value: -50"} + + +async def test_capability_custom_range_no_service(hass): + state = State("switch.test", STATE_ON, {}) + cap = CustomRangeCapability( + hass, + BASIC_CONFIG, + state, + RangeCapabilityInstance.OPEN, + {}, + ) + assert cap.supported is True + assert cap.support_random_access is False + assert cap.retrievable is False + assert cap.get_value() is None + + with pytest.raises(SmartHomeError) as e: + await cap.set_instance_state( + Context(), + RangeCapabilityInstanceActionState(instance=RangeCapabilityInstance.OPEN, value=10), + ) + + assert e.value.code == const.ERR_INTERNAL_ERROR + assert e.value.message == "Missing capability service" diff --git a/tests/test_schema.py b/tests/test_schema.py index 6181690a..112f5950 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -3,6 +3,7 @@ from custom_components.yandex_smart_home.schema import DevicesActionRequest, GetStreamInstanceActionStateValue from custom_components.yandex_smart_home.schema.capability import * from custom_components.yandex_smart_home.schema.capability_color import * +from custom_components.yandex_smart_home.schema.capability_mode import * def test_devices_action_request():