Add set_cover_position_and_tilt service to Overkiz (#169275)

Co-authored-by: optimusbasti <optimusbasti@users.noreply.github.com>
Co-authored-by: ThomasCZ <noreply@users.github.com>
This commit is contained in:
optimusbasti
2026-05-04 20:23:26 +02:00
committed by GitHub
co-authored by optimusbasti ThomasCZ
parent 2846dcc035
commit fcd23353f2
7 changed files with 258 additions and 3 deletions
+15 -1
View File
@@ -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
+31 -1
View File
@@ -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:
@@ -42,5 +42,10 @@
}
}
}
},
"services": {
"set_cover_position_and_tilt": {
"service": "mdi:window-shutter-cog"
}
}
}
@@ -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
],
)
@@ -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: "%"
@@ -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"
}
}
}
+119 -1
View File
@@ -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