mirror of
https://github.com/home-assistant/core.git
synced 2026-08-28 10:16:02 -05:00
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:
co-authored by
optimusbasti
ThomasCZ
parent
2846dcc035
commit
fcd23353f2
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user