diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index e7ecd008a1a4..eeca97dc4756 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -28,8 +28,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_API_TYPE, @@ -42,6 +47,9 @@ from .const import ( UPDATE_INTERVAL_LOCAL, ) from .coordinator import OverkizDataUpdateCoordinator +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @dataclass @@ -56,6 +64,12 @@ class HomeAssistantOverkizData: type OverkizDataConfigEntry = ConfigEntry[HomeAssistantOverkizData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Overkiz component.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) -> bool: """Set up Overkiz from a config entry.""" client: OverkizClient | None = None diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 92626c19ce84..9eb3b3d079d8 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -22,10 +22,11 @@ from homeassistant.components.cover import ( ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry -from .const import LOGGER +from .const import DOMAIN, LOGGER from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity @@ -506,6 +507,35 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity): if command := self.entity_description.set_tilt_position_command: await self.executor.async_execute_command(command, position) + async def async_set_cover_position_and_tilt(self, **kwargs: Any) -> None: + """Move cover and tilt to a specific position simultaneously. + + Exposed as the `overkiz.set_cover_position_and_tilt` service action. Uses the + setClosureAndOrientation command to move slats and closure in a single instruction. + Calling set_cover_position and set_cover_tilt_position sequentially will cause + the motor to stop between commands on some devices (e.g. Somfy + DynamicExteriorVenetianBlind). + """ + if not self.executor.has_command(OverkizCommand.SET_CLOSURE_AND_ORIENTATION): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_set_position_and_tilt", + ) + + position = kwargs[ATTR_POSITION] + tilt_position = kwargs[ATTR_TILT_POSITION] + + if self.entity_description.invert_position: + position = 100 - position + if self.entity_description.invert_tilt_position: + tilt_position = 100 - tilt_position + + await self.executor.async_execute_command( + OverkizCommand.SET_CLOSURE_AND_ORIENTATION, + position, + tilt_position, + ) + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" if command := self.entity_description.open_tilt_command: diff --git a/homeassistant/components/overkiz/icons.json b/homeassistant/components/overkiz/icons.json index 6e5db404e172..579155b64f8d 100644 --- a/homeassistant/components/overkiz/icons.json +++ b/homeassistant/components/overkiz/icons.json @@ -42,5 +42,10 @@ } } } + }, + "services": { + "set_cover_position_and_tilt": { + "service": "mdi:window-shutter-cog" + } } } diff --git a/homeassistant/components/overkiz/services.py b/homeassistant/components/overkiz/services.py new file mode 100644 index 000000000000..e42cf2b22eaf --- /dev/null +++ b/homeassistant/components/overkiz/services.py @@ -0,0 +1,44 @@ +"""Services for the Overkiz integration.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import service + +from .const import DOMAIN + +SERVICE_SET_COVER_POSITION_AND_TILT = "set_cover_position_and_tilt" + +POSITION_MIN = 0 +POSITION_MAX = 100 + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Overkiz integration.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_COVER_POSITION_AND_TILT, + entity_domain=COVER_DOMAIN, + schema={ + vol.Required(ATTR_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=POSITION_MIN, max=POSITION_MAX) + ), + vol.Required(ATTR_TILT_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=POSITION_MIN, max=POSITION_MAX) + ), + }, + func="async_set_cover_position_and_tilt", + required_features=[ + CoverEntityFeature.SET_POSITION | CoverEntityFeature.SET_TILT_POSITION + ], + ) diff --git a/homeassistant/components/overkiz/services.yaml b/homeassistant/components/overkiz/services.yaml new file mode 100644 index 000000000000..f51b602d9625 --- /dev/null +++ b/homeassistant/components/overkiz/services.yaml @@ -0,0 +1,23 @@ +set_cover_position_and_tilt: + target: + entity: + integration: overkiz + domain: cover + supported_features: + - - cover.CoverEntityFeature.SET_POSITION + - cover.CoverEntityFeature.SET_TILT_POSITION + fields: + position: + required: true + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + tilt_position: + required: true + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 7e55067e80b7..9da79f900732 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -179,5 +179,26 @@ } } } + }, + "exceptions": { + "unsupported_set_position_and_tilt": { + "message": "This device does not support setting position and tilt simultaneously." + } + }, + "services": { + "set_cover_position_and_tilt": { + "description": "Moves the cover and tilt to the target position simultaneously, preventing the motor from stopping between movements.", + "fields": { + "position": { + "description": "Target vertical position. 0 means closed, 100 means fully open.", + "name": "Position" + }, + "tilt_position": { + "description": "Target tilt position. 0 means closed, 100 means fully open.", + "name": "Tilt position" + } + }, + "name": "Set cover position and tilt" + } } } diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 46f594b9fdf9..21bcc495ce21 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -29,6 +29,7 @@ from homeassistant.components.cover import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration @@ -75,7 +76,7 @@ TILTED_WINDOW = FixtureDevice( DYNAMIC_EXTERIOR_VENETIAN_BLIND = FixtureDevice( "setup/local_somfy_tahoma_switch_europe.json", "io://1234-5678-6508/4877511", - "cover.dining_room_blinds", + "cover.office_blinds", ) # Device with ClosureState=124 POSITIONABLE_ROLLER_SHUTTER_UNO = FixtureDevice( @@ -800,3 +801,120 @@ async def test_low_speed_cover_open_close( command_name="setClosureAndLinearSpeed", parameters=[100, OverkizCommandParam.LOWSPEED], ) + + +async def test_set_cover_position_and_tilt_service_is_registered( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, +) -> None: + """The overkiz.set_cover_position_and_tilt service must be registered.""" + await setup_overkiz_integration(fixture=DYNAMIC_EXTERIOR_VENETIAN_BLIND.fixture) + + assert hass.services.has_service("overkiz", "set_cover_position_and_tilt") + + +async def test_set_cover_position_and_tilt_executes_single_command( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, +) -> None: + """Position+tilt must be sent as one atomic setClosureAndOrientation call. + + Replaces two sequential set_cover_position + set_cover_tilt_position calls, + which cause Somfy motors to stop mid-movement between commands. + """ + await setup_overkiz_integration(fixture=DYNAMIC_EXTERIOR_VENETIAN_BLIND.fixture) + + await hass.services.async_call( + "overkiz", + "set_cover_position_and_tilt", + { + ATTR_ENTITY_ID: DYNAMIC_EXTERIOR_VENETIAN_BLIND.entity_id, + ATTR_POSITION: 30, + ATTR_TILT_POSITION: 80, + }, + blocking=True, + ) + + # Home Assistant position 30 -> Overkiz closure 70 (inverted), + # tilt 80 -> orientation 20 (inverted). + assert_command_call( + mock_client, + device_url=DYNAMIC_EXTERIOR_VENETIAN_BLIND.device_url, + command_name="setClosureAndOrientation", + parameters=[70, 20], + ) + + +@pytest.mark.parametrize( + ("position", "tilt_position", "expected_parameters"), + [ + (0, 100, [100, 0]), + (100, 0, [0, 100]), + (50, 50, [50, 50]), + ], + ids=["closed-tilt-open", "open-tilt-closed", "midpoint"], +) +async def test_set_cover_position_and_tilt_inverts_boundaries( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, + position: int, + tilt_position: int, + expected_parameters: list[int], +) -> None: + """Boundary and midpoint values must invert consistently.""" + await setup_overkiz_integration(fixture=DYNAMIC_EXTERIOR_VENETIAN_BLIND.fixture) + + await hass.services.async_call( + "overkiz", + "set_cover_position_and_tilt", + { + ATTR_ENTITY_ID: DYNAMIC_EXTERIOR_VENETIAN_BLIND.entity_id, + ATTR_POSITION: position, + ATTR_TILT_POSITION: tilt_position, + }, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=DYNAMIC_EXTERIOR_VENETIAN_BLIND.device_url, + command_name="setClosureAndOrientation", + parameters=expected_parameters, + ) + + +async def test_set_cover_position_and_tilt_unsupported_command_raises( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, +) -> None: + """ServiceValidationError must be raised when SET_CLOSURE_AND_ORIENTATION is missing. + + Defence-in-depth: even when a cover advertises both SET_POSITION and + SET_TILT_POSITION (so it passes the ``required_features`` filter), the + handler still checks the atomic command and aborts cleanly if it is + missing. + """ + await setup_overkiz_integration(fixture=DYNAMIC_EXTERIOR_VENETIAN_BLIND.fixture) + + with ( + patch( + "homeassistant.components.overkiz.executor.OverkizExecutor.has_command", + return_value=False, + ), + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + "overkiz", + "set_cover_position_and_tilt", + { + ATTR_ENTITY_ID: DYNAMIC_EXTERIOR_VENETIAN_BLIND.entity_id, + ATTR_POSITION: 50, + ATTR_TILT_POSITION: 50, + }, + blocking=True, + ) + + assert mock_client.execute_command.await_count == 0