diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index d8824519be01de..fe94a1d65efc5f 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -23,6 +23,7 @@ SERVICE_TOGGLE_COVER_TILT, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent @@ -35,11 +36,13 @@ ATTR_CURRENT_TILT_POSITION, ATTR_IS_CLOSED, ATTR_POSITION, + ATTR_SPEED, ATTR_TILT_POSITION, DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER, CoverDeviceClass, + CoverEntityCapabilityAttribute, CoverEntityFeature, CoverEntityStateAttribute, CoverState, @@ -65,6 +68,7 @@ "ATTR_CURRENT_TILT_POSITION", "ATTR_IS_CLOSED", "ATTR_POSITION", + "ATTR_SPEED", "ATTR_TILT_POSITION", "DEVICE_CLASSES", "DEVICE_CLASSES_SCHEMA", @@ -75,6 +79,7 @@ "PLATFORM_SCHEMA_BASE", "CoverDeviceClass", "CoverEntity", + "CoverEntityCapabilityAttribute", "CoverEntityDescription", "CoverEntityFeature", "CoverEntityStateAttribute", @@ -100,11 +105,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) component.async_register_entity_service( - SERVICE_OPEN_COVER, None, "async_open_cover", [CoverEntityFeature.OPEN] + SERVICE_OPEN_COVER, + {vol.Optional(ATTR_SPEED): cv.string}, + "async_handle_open_cover", + [CoverEntityFeature.OPEN], ) component.async_register_entity_service( - SERVICE_CLOSE_COVER, None, "async_close_cover", [CoverEntityFeature.CLOSE] + SERVICE_CLOSE_COVER, + {vol.Optional(ATTR_SPEED): cv.string}, + "async_handle_close_cover", + [CoverEntityFeature.CLOSE], ) component.async_register_entity_service( @@ -112,9 +123,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Required(ATTR_POSITION): vol.All( vol.Coerce(int), vol.Range(min=0, max=100) - ) + ), + vol.Optional(ATTR_SPEED): cv.string, }, - "async_set_cover_position", + "async_handle_set_cover_position", [CoverEntityFeature.SET_POSITION], ) @@ -194,6 +206,7 @@ class CoverEntityDescription(EntityDescription, frozen_or_thawed=True): "is_opening", "is_closing", "is_closed", + "supported_speeds", } @@ -209,9 +222,16 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_is_opening: bool | None = None _attr_state: None = None _attr_supported_features: CoverEntityFeature | None + _attr_supported_speeds: list[str] | None = None _cover_is_last_toggle_direction_open = True + _entity_component_unrecorded_attributes = frozenset( + { + CoverEntityCapabilityAttribute.SUPPORTED_SPEEDS, + } + ) + @cached_property def current_cover_position(self) -> int | None: """Return current position of cover. @@ -283,6 +303,9 @@ def supported_features(self) -> CoverEntityFeature: CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP ) + if self.supported_speeds: + supported_features |= CoverEntityFeature.SPEED + if self.current_cover_position is not None: supported_features |= CoverEntityFeature.SET_POSITION @@ -311,6 +334,73 @@ def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" return self._attr_is_closed + @cached_property + def supported_speeds(self) -> list[str] | None: + """Return the list of speeds supported for open/close/set_position operations. + + None or an empty list means speed selection is not supported. + """ + return self._attr_supported_speeds + + @property + @override + def capability_attributes(self) -> dict[str, Any] | None: + """Return capability attributes.""" + + if speeds := self.supported_speeds: + return {CoverEntityCapabilityAttribute.SUPPORTED_SPEEDS: speeds} + return None + + @final + def _valid_speed_or_raise(self, speed: str, supported: list[str]) -> None: + """Raise ServiceValidationError if speed is not in the supported list.""" + if speed not in supported: + supported_str = ", ".join(supported) + raise ServiceValidationError( + translation_key="not_valid_speed", + translation_domain=DOMAIN, + translation_placeholders={ + "speed": speed, + "supported_speeds": supported_str, + }, + ) + + @final + async def async_handle_open_cover(self, **kwargs: Any) -> None: + """Validate speed and open the cover.""" + call_kwargs = kwargs + if (speed := kwargs.get(ATTR_SPEED)) is not None: + if speeds := self.supported_speeds: + self._valid_speed_or_raise(speed, speeds) + else: + call_kwargs = dict(kwargs) + call_kwargs.pop(ATTR_SPEED) + await self.async_open_cover(**call_kwargs) + + @final + async def async_handle_close_cover(self, **kwargs: Any) -> None: + """Validate speed and close the cover.""" + call_kwargs = kwargs + if (speed := kwargs.get(ATTR_SPEED)) is not None: + if speeds := self.supported_speeds: + self._valid_speed_or_raise(speed, speeds) + else: + call_kwargs = dict(kwargs) + call_kwargs.pop(ATTR_SPEED) + await self.async_close_cover(**call_kwargs) + + @final + async def async_handle_set_cover_position(self, **kwargs: Any) -> None: + """Validate speed and move the cover to a specific position.""" + call_kwargs = kwargs + if (speed := kwargs.get(ATTR_SPEED)) is not None: + if speeds := self.supported_speeds: + self._valid_speed_or_raise(speed, speeds) + else: + call_kwargs = dict(kwargs) + call_kwargs.pop(ATTR_SPEED) + await self.async_set_cover_position(**call_kwargs) + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" raise NotImplementedError diff --git a/homeassistant/components/cover/const.py b/homeassistant/components/cover/const.py index 9a477fe852bf8e..b02ee3324a109c 100644 --- a/homeassistant/components/cover/const.py +++ b/homeassistant/components/cover/const.py @@ -8,9 +8,16 @@ ATTR_CURRENT_TILT_POSITION = "current_tilt_position" ATTR_IS_CLOSED = "is_closed" ATTR_POSITION = "position" +ATTR_SPEED = "speed" ATTR_TILT_POSITION = "tilt_position" +class CoverEntityCapabilityAttribute(StrEnum): + """Capability attributes for cover entities.""" + + SUPPORTED_SPEEDS = "supported_speeds" + + class CoverEntityStateAttribute(StrEnum): """State attributes for cover entities.""" @@ -34,6 +41,7 @@ class CoverEntityFeature(IntFlag): CLOSE_TILT = 32 STOP_TILT = 64 SET_TILT_POSITION = 128 + SPEED = 256 class CoverState(StrEnum): diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 9f9e37941e2ccb..a1994595a61db1 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -6,6 +6,15 @@ open_cover: domain: cover supported_features: - cover.CoverEntityFeature.OPEN + fields: + speed: + example: "fast" + filter: + supported_features: + - cover.CoverEntityFeature.SPEED + selector: + state: + attribute: speed close_cover: target: @@ -13,6 +22,15 @@ close_cover: domain: cover supported_features: - cover.CoverEntityFeature.CLOSE + fields: + speed: + example: "fast" + filter: + supported_features: + - cover.CoverEntityFeature.SPEED + selector: + state: + attribute: speed toggle: target: @@ -36,6 +54,14 @@ set_cover_position: min: 0 max: 100 unit_of_measurement: "%" + speed: + filter: + supported_features: + - cover.CoverEntityFeature.SPEED + example: "fast" + selector: + state: + attribute: speed stop_cover: target: diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 98b21d7cbb9952..dbe4c4744dd3d4 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -2,6 +2,7 @@ "common": { "condition_behavior_name": "Condition passes if", "condition_for_name": "For at least", + "field_speed_name": "Speed", "trigger_behavior_name": "Trigger when", "trigger_for_name": "For at least" }, @@ -210,9 +211,20 @@ "name": "Window" } }, + "exceptions": { + "not_valid_speed": { + "message": "Speed {speed} is not valid. Valid speeds are: {supported_speeds}." + } + }, "services": { "close_cover": { "description": "Closes a cover.", + "fields": { + "speed": { + "description": "Speed at which to close the cover.", + "name": "[%key:component::cover::common::field_speed_name%]" + } + }, "name": "Close cover" }, "close_cover_tilt": { @@ -221,6 +233,12 @@ }, "open_cover": { "description": "Opens a cover.", + "fields": { + "speed": { + "description": "Speed at which to open the cover.", + "name": "[%key:component::cover::common::field_speed_name%]" + } + }, "name": "Open cover" }, "open_cover_tilt": { @@ -233,6 +251,10 @@ "position": { "description": "Target position.", "name": "Position" + }, + "speed": { + "description": "Speed at which to move the cover.", + "name": "[%key:component::cover::common::field_speed_name%]" } }, "name": "Set cover position" diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 4d2d14961b8d72..397408783990ae 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -29,7 +29,15 @@ async def async_setup_entry( [ DemoCover(hass, "cover_1", "Kitchen Window"), DemoCover(hass, "cover_2", "Hall Window", 10), - DemoCover(hass, "cover_3", "Living Room Window", 70, 50), + DemoCover( + hass, + "cover_3", + "Living Room Window", + 70, + 50, + supported_speeds=["slow", "default"], + translation_key="living_room_window", + ), DemoCover( hass, "cover_4", @@ -69,6 +77,8 @@ def __init__( tilt_position: int | None = None, device_class: CoverDeviceClass | None = None, supported_features: CoverEntityFeature | None = None, + supported_speeds: list[str] | None = None, + translation_key: str | None = None, ) -> None: """Initialize the cover.""" self.hass = hass @@ -76,6 +86,8 @@ def __init__( self._position = position self._attr_device_class = device_class self._attr_supported_features = supported_features + self._attr_supported_speeds = supported_speeds + self._attr_translation_key = translation_key self._set_position: int | None = None self._set_tilt_position: int | None = None self._tilt_position = tilt_position diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index 6ff396f285e53d..14cf9cb96c922f 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -30,6 +30,18 @@ } } }, + "cover": { + "living_room_window": { + "state_attributes": { + "speed": { + "state": { + "default": "Default", + "slow": "Slow" + } + } + } + } + }, "event": { "push": { "state_attributes": { diff --git a/tests/components/cover/common.py b/tests/components/cover/common.py index b4a0cdb06d411e..6d8f48863b0392 100644 --- a/tests/components/cover/common.py +++ b/tests/components/cover/common.py @@ -21,6 +21,7 @@ def __init__( if reports_opening_closing is not None else CoverEntityFeature.STOP in self.supported_features ) + self.last_kwargs: dict[str, Any] | None = None @property def is_closed(self): @@ -48,6 +49,7 @@ def is_closing(self): def open_cover(self, **kwargs) -> None: """Open cover.""" + self.last_kwargs = kwargs if self._reports_opening_closing: self._values["state"] = CoverState.OPENING else: @@ -55,11 +57,16 @@ def open_cover(self, **kwargs) -> None: def close_cover(self, **kwargs) -> None: """Close cover.""" + self.last_kwargs = kwargs if self._reports_opening_closing: self._values["state"] = CoverState.CLOSING else: self._values["state"] = CoverState.CLOSED + def set_cover_position(self, **kwargs) -> None: + """Set cover position.""" + self.last_kwargs = kwargs + def stop_cover(self, **kwargs) -> None: """Stop cover.""" assert CoverEntityFeature.STOP in self.supported_features @@ -74,3 +81,8 @@ def current_cover_position(self): def current_cover_tilt_position(self): """Return current position of cover tilt.""" return self._handle("current_cover_tilt_position") + + @property + def supported_speeds(self): + """Return supported speeds of cover.""" + return self._handle("supported_speeds") diff --git a/tests/components/cover/conftest.py b/tests/components/cover/conftest.py index 1fc0de1fc2e0e5..e4d2a1dddf080d 100644 --- a/tests/components/cover/conftest.py +++ b/tests/components/cover/conftest.py @@ -64,4 +64,14 @@ def mock_cover_entities() -> list[MockCover]: supported_features=CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, reports_opening_closing=True, ), + MockCover( + name="Cover with speed support", + unique_id="unique_speed_cover", + current_cover_position=50, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION, + supported_speeds=["slow", "fast", "default"], + ), ] diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index faecca10e24fd5..e6694a860cc967 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -1,9 +1,20 @@ """The tests for Cover.""" +import pytest + from homeassistant.components import cover -from homeassistant.components.cover import CoverState -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, SERVICE_TOGGLE +from homeassistant.components.cover import ATTR_SPEED, CoverState +from homeassistant.components.cover.const import ATTR_POSITION +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_PLATFORM, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_TOGGLE, +) from homeassistant.core import HomeAssistant, ServiceResponse +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity import Entity from homeassistant.setup import async_setup_component @@ -30,7 +41,7 @@ async def test_services( # ent4 = cover with all tilt functions but no position # ent5 = cover with all functions # ent6 = cover with only open/close, but also reports opening/closing - ent1, ent2, ent3, ent4, ent5, ent6 = mock_cover_entities + ent1, ent2, ent3, ent4, ent5, ent6, _ = mock_cover_entities # Test init all covers should be open assert is_open(hass, ent1) @@ -191,3 +202,121 @@ def is_closing(hass: HomeAssistant, ent: Entity, position: int | None = None) -> expected_position=position, expected_is_closed=False, ) + + +async def test_services_with_speed( + hass: HomeAssistant, + mock_cover_entities: list[MockCover], +) -> None: + """Test speed validation in cover services.""" + setup_test_component_platform(hass, cover.DOMAIN, mock_cover_entities) + + assert await async_setup_component( + hass, cover.DOMAIN, {cover.DOMAIN: {CONF_PLATFORM: "test"}} + ) + await hass.async_block_till_done() + + # ent1 = cover without tilt and position and no speed support + # ent2 = cover with position but no speed support + # ent3 .. ent6 not needed in this test + # speed_cover = cover with speed support + ent1, ent2, _, _, _, _, speed_cover = mock_cover_entities + + state = hass.states.get(speed_cover.entity_id) + assert state.attributes["supported_speeds"] == ["slow", "fast", "default"] + + speed_cover.last_kwargs = None + await hass.services.async_call( + cover.DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: speed_cover.entity_id, ATTR_SPEED: "fast"}, + blocking=True, + ) + assert speed_cover.last_kwargs == {"speed": "fast"} + assert is_opening(hass, speed_cover, 50) + + speed_cover.last_kwargs = None + await hass.services.async_call( + cover.DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: speed_cover.entity_id, ATTR_SPEED: "slow"}, + blocking=True, + ) + assert speed_cover.last_kwargs == {"speed": "slow"} + assert is_closing(hass, speed_cover, 50) + + speed_cover.last_kwargs = None + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_POSITION, + { + ATTR_ENTITY_ID: speed_cover.entity_id, + ATTR_POSITION: 75, + ATTR_SPEED: "default", + }, + blocking=True, + ) + assert speed_cover.last_kwargs == {"position": 75, "speed": "default"} + + speed_cover.last_kwargs = None + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + cover.DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: speed_cover.entity_id, ATTR_SPEED: "invalid"}, + blocking=True, + ) + assert speed_cover.last_kwargs is None + assert exc.value.translation_key == "not_valid_speed" + + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + cover.DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: speed_cover.entity_id, ATTR_SPEED: "invalid"}, + blocking=True, + ) + assert speed_cover.last_kwargs is None + assert exc.value.translation_key == "not_valid_speed" + + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_POSITION, + { + ATTR_ENTITY_ID: speed_cover.entity_id, + ATTR_POSITION: 50, + ATTR_SPEED: "invalid", + }, + blocking=True, + ) + assert speed_cover.last_kwargs is None + assert exc.value.translation_key == "not_valid_speed" + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ent1.entity_id, ATTR_SPEED: "ignore"}, + blocking=True, + ) + assert is_open(hass, ent1) + assert ent1.last_kwargs == {} + + ent1.last_kwargs = None + await hass.services.async_call( + cover.DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ent1.entity_id, ATTR_SPEED: "ignore"}, + blocking=True, + ) + assert is_closed(hass, ent1) + assert ent1.last_kwargs == {} + + ent2.last_kwargs = None + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ent2.entity_id, ATTR_POSITION: 49, ATTR_SPEED: "ignore"}, + blocking=True, + ) + assert ent2.last_kwargs == {"position": 49} diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py index 74ee6e6b11ee90..5e76f8f50f1472 100644 --- a/tests/components/demo/test_cover.py +++ b/tests/components/demo/test_cover.py @@ -66,7 +66,7 @@ async def test_supported_features(hass: HomeAssistant) -> None: state = hass.states.get("cover.hall_window") assert state.attributes[ATTR_SUPPORTED_FEATURES] == 15 state = hass.states.get("cover.living_room_window") - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 255 + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 511 async def test_close_cover(hass: HomeAssistant) -> None: @@ -76,7 +76,10 @@ async def test_close_cover(hass: HomeAssistant) -> None: assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( - COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, + blocking=True, ) state = hass.states.get(ENTITY_COVER) assert state.state == CoverState.CLOSING @@ -96,7 +99,10 @@ async def test_open_cover(hass: HomeAssistant) -> None: assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( - COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, + blocking=True, ) state = hass.states.get(ENTITY_COVER) assert state.state == CoverState.OPENING