From d388ffc1cbfc7597fbeb9459a5471001d28e8b40 Mon Sep 17 00:00:00 2001 From: wollew Date: Wed, 26 Aug 2026 19:06:30 +0200 Subject: [PATCH] Support speed parameter in Velux covers (#180036) Co-authored-by: Joostlek --- homeassistant/components/velux/cover.py | 38 +++- homeassistant/components/velux/strings.json | 33 +++- .../velux/snapshots/test_cover.ambr | 167 ++++++++++++++---- .../velux/snapshots/test_diagnostics.ambr | 7 +- tests/components/velux/test_cover.py | 62 +++++-- 5 files changed, 255 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index cec443630cef..8c48c0cf3717 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -3,6 +3,7 @@ from enum import StrEnum from typing import Any, override +from pyvlx.const import Velocity from pyvlx.opening_device import ( Awning, Blind, @@ -17,6 +18,7 @@ from pyvlx.opening_device import ( from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_SPEED, ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, @@ -70,6 +72,7 @@ class VeluxCover(VeluxEntity, CoverEntity): """Representation of a Velux cover.""" node: OpeningDevice + _attr_translation_key = "cover" # Features common to all covers _attr_supported_features = ( @@ -77,7 +80,19 @@ class VeluxCover(VeluxEntity, CoverEntity): | CoverEntityFeature.CLOSE | CoverEntityFeature.SET_POSITION | CoverEntityFeature.STOP + | CoverEntityFeature.SPEED ) + _attr_supported_speeds = [ + Velocity.SILENT.name.lower(), + Velocity.FAST.name.lower(), + Velocity.DEFAULT.name.lower(), + ] + + def _velocity_from_speed(self, speed: str | None) -> Velocity | None: + """Return pyvlx Velocity for the given speed string, or None for default.""" + if speed in self._attr_supported_speeds: + return Velocity[speed.upper()] + return None def __init__( self, hass: HomeAssistant, node: OpeningDevice, config_entry_id: str @@ -128,22 +143,27 @@ class VeluxCover(VeluxEntity, CoverEntity): @override async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self.node.close(wait_for_completion=False) + velocity = self._velocity_from_speed(kwargs.get(ATTR_SPEED)) + await self.node.close(velocity=velocity, wait_for_completion=False) @wrap_pyvlx_call_exceptions @override async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self.node.open(wait_for_completion=False) + velocity = self._velocity_from_speed(kwargs.get(ATTR_SPEED)) + await self.node.open(velocity=velocity, wait_for_completion=False) @wrap_pyvlx_call_exceptions @override async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position_percent = 100 - kwargs[ATTR_POSITION] + velocity = self._velocity_from_speed(kwargs.get(ATTR_SPEED)) await self.node.set_position( - Position(position_percent=position_percent), wait_for_completion=False + Position(position_percent=position_percent), + velocity=velocity, + wait_for_completion=False, ) @wrap_pyvlx_call_exceptions @@ -214,23 +234,31 @@ class VeluxDualRollerShutter(VeluxCover): @override async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self.node.close(curtain=self.part, wait_for_completion=False) + velocity = self._velocity_from_speed(kwargs.get(ATTR_SPEED)) + await self.node.close( + curtain=self.part, velocity=velocity, wait_for_completion=False + ) @wrap_pyvlx_call_exceptions @override async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self.node.open(curtain=self.part, wait_for_completion=False) + velocity = self._velocity_from_speed(kwargs.get(ATTR_SPEED)) + await self.node.open( + curtain=self.part, velocity=velocity, wait_for_completion=False + ) @wrap_pyvlx_call_exceptions @override async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position_percent = 100 - kwargs[ATTR_POSITION] + velocity = self._velocity_from_speed(kwargs.get(ATTR_SPEED)) await self.node.set_position( Position(position_percent=position_percent), curtain=self.part, + velocity=velocity, wait_for_completion=False, ) diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 9a7484f820ee..fb1ea00ed9a4 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -47,11 +47,40 @@ } }, "cover": { + "cover": { + "state_attributes": { + "speed": { + "state": { + "default": "Default", + "fast": "Fast", + "silent": "Silent" + } + } + } + }, "dual_roller_shutter_lower": { - "name": "Lower shutter" + "name": "Lower shutter", + "state_attributes": { + "speed": { + "state": { + "default": "[%key:component::velux::entity::cover::cover::state_attributes::speed::state::default%]", + "fast": "[%key:component::velux::entity::cover::cover::state_attributes::speed::state::fast%]", + "silent": "[%key:component::velux::entity::cover::cover::state_attributes::speed::state::silent%]" + } + } + } }, "dual_roller_shutter_upper": { - "name": "Upper shutter" + "name": "Upper shutter", + "state_attributes": { + "speed": { + "state": { + "default": "[%key:component::velux::entity::cover::cover::state_attributes::speed::state::default%]", + "fast": "[%key:component::velux::entity::cover::cover::state_attributes::speed::state::fast%]", + "silent": "[%key:component::velux::entity::cover::cover::state_attributes::speed::state::silent%]" + } + } + } } }, "number": { diff --git a/tests/components/velux/snapshots/test_cover.ambr b/tests/components/velux/snapshots/test_cover.ambr index 9c2d88de3cc5..7af4e821e8fd 100644 --- a/tests/components/velux/snapshots/test_cover.ambr +++ b/tests/components/velux/snapshots/test_cover.ambr @@ -5,7 +5,13 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'silent', + 'fast', + 'default', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -30,8 +36,8 @@ 'platform': 'velux', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'cover', 'unique_id': '4711', 'unit_of_measurement': None, }) @@ -44,7 +50,12 @@ : 'blind', : 'Test Blind', : False, - : , + : , + : list([ + 'silent', + 'fast', + 'default', + ]), }), 'context': , 'entity_id': 'cover.test_blind', @@ -60,7 +71,13 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'silent', + 'fast', + 'default', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -85,8 +102,8 @@ 'platform': 'velux', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'cover', 'unique_id': 'serial_Awning', 'unit_of_measurement': None, }) @@ -98,7 +115,12 @@ : 'awning', : 'Test Awning', : False, - : , + : , + : list([ + 'silent', + 'fast', + 'default', + ]), }), 'context': , 'entity_id': 'cover.test_awning', @@ -114,7 +136,13 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'silent', + 'fast', + 'default', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -139,8 +167,8 @@ 'platform': 'velux', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'cover', 'unique_id': 'serial_DualRollerShutter', 'unit_of_measurement': None, }) @@ -152,7 +180,12 @@ : 'shutter', : 'Test DualRollerShutter', : False, - : , + : , + : list([ + 'silent', + 'fast', + 'default', + ]), }), 'context': , 'entity_id': 'cover.test_dualrollershutter', @@ -168,7 +201,13 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'silent', + 'fast', + 'default', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -193,7 +232,7 @@ 'platform': 'velux', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'dual_roller_shutter_lower', 'unique_id': 'serial_DualRollerShutter_lower', 'unit_of_measurement': None, @@ -206,7 +245,12 @@ : 'shutter', : 'Test DualRollerShutter Lower shutter', : False, - : , + : , + : list([ + 'silent', + 'fast', + 'default', + ]), }), 'context': , 'entity_id': 'cover.test_dualrollershutter_lower_shutter', @@ -222,7 +266,13 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'silent', + 'fast', + 'default', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -247,7 +297,7 @@ 'platform': 'velux', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'dual_roller_shutter_upper', 'unique_id': 'serial_DualRollerShutter_upper', 'unit_of_measurement': None, @@ -260,7 +310,12 @@ : 'shutter', : 'Test DualRollerShutter Upper shutter', : False, - : , + : , + : list([ + 'silent', + 'fast', + 'default', + ]), }), 'context': , 'entity_id': 'cover.test_dualrollershutter_upper_shutter', @@ -276,7 +331,13 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'silent', + 'fast', + 'default', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -301,8 +362,8 @@ 'platform': 'velux', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'cover', 'unique_id': 'serial_GarageDoor', 'unit_of_measurement': None, }) @@ -314,7 +375,12 @@ : 'garage', : 'Test GarageDoor', : False, - : , + : , + : list([ + 'silent', + 'fast', + 'default', + ]), }), 'context': , 'entity_id': 'cover.test_garagedoor', @@ -330,7 +396,13 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'silent', + 'fast', + 'default', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -355,8 +427,8 @@ 'platform': 'velux', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'cover', 'unique_id': 'serial_Gate', 'unit_of_measurement': None, }) @@ -368,7 +440,12 @@ : 'gate', : 'Test Gate', : False, - : , + : , + : list([ + 'silent', + 'fast', + 'default', + ]), }), 'context': , 'entity_id': 'cover.test_gate', @@ -384,7 +461,13 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'silent', + 'fast', + 'default', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -409,8 +492,8 @@ 'platform': 'velux', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'cover', 'unique_id': 'serial_RollerShutter', 'unit_of_measurement': None, }) @@ -422,7 +505,12 @@ : 'shutter', : 'Test RollerShutter', : False, - : , + : , + : list([ + 'silent', + 'fast', + 'default', + ]), }), 'context': , 'entity_id': 'cover.test_rollershutter', @@ -438,7 +526,13 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + : list([ + 'silent', + 'fast', + 'default', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -463,8 +557,8 @@ 'platform': 'velux', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, + 'supported_features': , + 'translation_key': 'cover', 'unique_id': 'serial_Window', 'unit_of_measurement': None, }) @@ -476,7 +570,12 @@ : 'window', : 'Test Window', : False, - : , + : , + : list([ + 'silent', + 'fast', + 'default', + ]), }), 'context': , 'entity_id': 'cover.test_window', diff --git a/tests/components/velux/snapshots/test_diagnostics.ambr b/tests/components/velux/snapshots/test_diagnostics.ambr index f5d08cdf0c26..f0b944fc3836 100644 --- a/tests/components/velux/snapshots/test_diagnostics.ambr +++ b/tests/components/velux/snapshots/test_diagnostics.ambr @@ -31,7 +31,12 @@ 'device_class': 'window', 'friendly_name': 'Test Window', 'is_closed': False, - 'supported_features': 15, + 'supported_features': 271, + 'supported_speeds': list([ + 'silent', + 'fast', + 'default', + ]), }), 'entity_id': 'cover.test_window', 'last_changed': '2025-01-01T00:00:00+00:00', diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py index 9544b2377ec4..859321fda595 100644 --- a/tests/components/velux/test_cover.py +++ b/tests/components/velux/test_cover.py @@ -1,8 +1,9 @@ """Tests for the Velux cover platform.""" -from unittest.mock import AsyncMock +from unittest.mock import ANY, AsyncMock import pytest +from pyvlx.const import Velocity from pyvlx.exception import PyVLXException from pyvlx.opening_device import ( Awning, @@ -15,6 +16,7 @@ from pyvlx.opening_device import ( from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_SPEED, ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, @@ -159,7 +161,7 @@ async def test_cover_closed( # Window command tests -async def test_window_open_close_stop_services( +async def test_window_open_close_stop_services_no_speed( hass: HomeAssistant, mock_window: AsyncMock ) -> None: """Verify open/close/stop services map to device calls with no wait.""" @@ -169,12 +171,12 @@ async def test_window_open_close_stop_services( await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True ) - mock_window.open.assert_awaited_once_with(wait_for_completion=False) + mock_window.open.assert_awaited_once_with(velocity=None, wait_for_completion=False) await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": entity_id}, blocking=True ) - mock_window.close.assert_awaited_once_with(wait_for_completion=False) + mock_window.close.assert_awaited_once_with(velocity=None, wait_for_completion=False) await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True @@ -182,6 +184,45 @@ async def test_window_open_close_stop_services( mock_window.stop.assert_awaited_once_with(wait_for_completion=False) +async def test_window_services_with_speed( + hass: HomeAssistant, mock_window: AsyncMock +) -> None: + """Verify open/close/stop services map to device calls with no wait.""" + + entity_id = "cover.test_window" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {"entity_id": entity_id, ATTR_SPEED: "silent"}, + blocking=True, + ) + mock_window.open.assert_awaited_once_with( + velocity=Velocity.SILENT, wait_for_completion=False + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {"entity_id": entity_id, ATTR_SPEED: "fast"}, + blocking=True, + ) + mock_window.close.assert_awaited_once_with( + velocity=Velocity.FAST, wait_for_completion=False + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {"entity_id": entity_id, ATTR_SPEED: "default", ATTR_POSITION: 50}, + blocking=True, + ) + mock_window.set_position.assert_awaited_once_with( + ANY, + velocity=Velocity.DEFAULT, + wait_for_completion=False, + ) + + async def test_window_set_cover_position_inversion( hass: HomeAssistant, mock_window: AsyncMock ) -> None: @@ -202,6 +243,7 @@ async def test_window_set_cover_position_inversion( position_obj = args[0] assert position_obj.position_percent == 70 assert kwargs.get("wait_for_completion") is False + assert kwargs.get("velocity") is None async def test_window_current_position_and_opening_closing_states( @@ -251,7 +293,7 @@ async def test_dual_roller_shutter_open_close_services( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": upper_entity_id}, blocking=True ) mock_dual_roller_shutter.open.assert_awaited_with( - curtain="upper", wait_for_completion=False + curtain="upper", velocity=None, wait_for_completion=False ) # Open lower part @@ -259,7 +301,7 @@ async def test_dual_roller_shutter_open_close_services( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": lower_entity_id}, blocking=True ) mock_dual_roller_shutter.open.assert_awaited_with( - curtain="lower", wait_for_completion=False + curtain="lower", velocity=None, wait_for_completion=False ) # Open dual @@ -267,7 +309,7 @@ async def test_dual_roller_shutter_open_close_services( COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": dual_entity_id}, blocking=True ) mock_dual_roller_shutter.open.assert_awaited_with( - curtain="dual", wait_for_completion=False + curtain="dual", velocity=None, wait_for_completion=False ) # Close upper part @@ -275,7 +317,7 @@ async def test_dual_roller_shutter_open_close_services( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": upper_entity_id}, blocking=True ) mock_dual_roller_shutter.close.assert_awaited_with( - curtain="upper", wait_for_completion=False + curtain="upper", velocity=None, wait_for_completion=False ) # Close lower part @@ -283,7 +325,7 @@ async def test_dual_roller_shutter_open_close_services( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": lower_entity_id}, blocking=True ) mock_dual_roller_shutter.close.assert_awaited_with( - curtain="lower", wait_for_completion=False + curtain="lower", velocity=None, wait_for_completion=False ) # Close dual @@ -291,7 +333,7 @@ async def test_dual_roller_shutter_open_close_services( COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": dual_entity_id}, blocking=True ) mock_dual_roller_shutter.close.assert_awaited_with( - curtain="dual", wait_for_completion=False + curtain="dual", velocity=None, wait_for_completion=False )