1
0
mirror of https://github.com/home-assistant/core.git synced 2026-06-01 21:24:17 +01:00
Files
2026-05-23 10:52:16 +02:00

1348 lines
42 KiB
Python

"""Tests for the Overkiz cover platform."""
from collections.abc import Generator
from pathlib import Path
from typing import Any
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from pyoverkiz.enums import EventName, ExecutionState, OverkizCommandParam, OverkizState
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
ATTR_POSITION,
ATTR_TILT_POSITION,
DOMAIN as COVER_DOMAIN,
SERVICE_CLOSE_COVER,
SERVICE_CLOSE_COVER_TILT,
SERVICE_OPEN_COVER,
SERVICE_OPEN_COVER_TILT,
SERVICE_SET_COVER_POSITION,
SERVICE_SET_COVER_TILT_POSITION,
SERVICE_STOP_COVER,
SERVICE_STOP_COVER_TILT,
CoverEntityFeature,
CoverState,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
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
from .helpers import assert_command_call, async_deliver_events, build_event
from tests.common import snapshot_platform
AWNING = FixtureDevice(
"setup/local_somfy_connexoon_europe.json",
"io://1234-1234-1234/5928357",
"cover.terrace_awning",
)
LOW_SPEED = FixtureDevice(
"setup/cloud_nexity_rail_din_europe.json",
"io://1234-5678-1698/141613",
"cover.nursery_shutter",
)
PERGOLA = FixtureDevice(
"setup/local_somfy_tahoma_v2_europe.json",
"io://1234-5678-3293/7614902",
"cover.garden_pergola",
)
UP_DOWN_BIOCLIMATIC_PERGOLA = FixtureDevice(
"setup/local_somfy_tahoma_v2_europe.json",
"rts://1234-5678-3293/16757826",
"cover.kitchen_pergola",
)
RTS = FixtureDevice(
"setup/cloud_somfy_connexoon_rts_asia.json",
"rts://1234-1234-6362/16730022",
"cover.patio_shutter",
)
SHUTTER = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-6233/12184029",
"cover.office_garden_house_shutter",
)
GARAGE = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-6233/1166863",
"cover.living_room_main_garage_door",
)
TILTED_WINDOW = FixtureDevice(
"setup/local_somfy_tahoma_switch_europe_3.json",
"io://1234-5678-9373/10202865",
"cover.main_bedroom_bedroom_blinds",
)
# Device with ClosureState=108
DYNAMIC_EXTERIOR_VENETIAN_BLIND = FixtureDevice(
"setup/local_somfy_tahoma_switch_europe.json",
"io://1234-5678-6508/4877511",
"cover.office_blinds",
)
# Device with ClosureState=124
POSITIONABLE_ROLLER_SHUTTER_UNO = FixtureDevice(
"setup/local_somfy_tahoma_switch_europe_2.json",
"io://1234-5678-1516/3656107",
"cover.maple_residence_hallway_shutter",
)
POSITIONABLE_DUAL_ROLLER_SHUTTER = FixtureDevice(
"setup/cloud_somfy_tahoma_switch_sc_europe.json",
"io://1234-5678-5010/12931361",
"cover.veranda_basement_roller_shutter",
)
TILT_ONLY_VENETIAN_BLIND = FixtureDevice(
"setup/cloud_somfy_connexoon_rts_asia.json",
"rts://1234-1234-6362/16730044",
"cover.palm_court_jaloezie",
)
UP_DOWN_VENETIAN_BLIND = FixtureDevice(
"setup/cloud_somfy_connexoon_rts_asia.json",
"rts://1234-1234-6362/16747291",
"cover.palm_court_office_venetian_blind",
)
UP_DOWN_SHEER_SCREEN = FixtureDevice(
"setup/cloud_somfy_connexoon_rts_asia.json",
"rts://1234-1234-6362/16753206",
"cover.palm_court_kitchen_sheer_screen",
)
DISCRETE_GARAGE_DOOR = FixtureDevice(
"setup/local_somfy_tahoma_v2_europe.json",
"io://1234-5678-3293/12745774",
"cover.garage_door_rollixo",
)
DYNAMIC_GARAGE_DOOR = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-6233/16730050",
"cover.living_room_garage_door",
)
DYNAMIC_GARAGE_DOOR_OGP = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"ogp://1234-1234-6233/6632544",
"cover.living_room_ogp_garage_door",
)
PARTIAL_GARAGE_DOOR = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-6233/7433515",
"cover.living_room_partial_garage_door",
)
RTS_GATE_4T = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"rts://1234-1234-6233/16730717",
"cover.living_room_rts_gate",
)
RTS_GARAGE_DOOR_4T = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"rts://1234-1234-6233/16721270",
"cover.living_room_rts_garage_door_4t",
)
CYCLIC_GARAGE_DOOR = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-6233/6416929",
"cover.living_room_cyclic_garage_door",
)
CYCLIC_SWINGING_GATE = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-8983/1959462",
"cover.living_room_swinging_gate",
)
SLIDING_DISCRETE_GATE = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-6233/16730051",
"cover.living_room_sliding_gate",
)
DYNAMIC_GATE = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"ogp://1234-1234-6233/10410217",
"cover.living_room_ogp_gate",
)
DYNAMIC_PERGOLA = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"ogp://1234-1234-6233/14356699",
"cover.living_room_somfy_pergola",
)
DYNAMIC_PERGOLA_TILT_ONLY = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"ogp://1234-1234-6233/10943109",
"cover.living_room_bioclimatic_pergola",
)
PERGOLA_HORIZONTAL_AWNING = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"io://1234-1234-6233/11447718",
"cover.living_room_pergola_awning",
)
DYNAMIC_VENETIAN_BLIND = FixtureDevice(
"setup/cloud_somfy_tahoma_v2_europe.json",
"ogp://1234-1234-6233/16730100",
"cover.main_bedroom_bedroom_venetian_blind",
)
POSITIONABLE_VENETIAN_BLIND = FixtureDevice(
"setup/local_somfy_tahoma_v2_europe.json",
"zigbee://1234-5678-3293/16730099",
"cover.living_room_venetian_blind",
)
SNAPSHOT_FIXTURES = [
AWNING,
LOW_SPEED,
PERGOLA,
RTS,
SHUTTER,
TILTED_WINDOW,
DYNAMIC_EXTERIOR_VENETIAN_BLIND,
POSITIONABLE_ROLLER_SHUTTER_UNO,
POSITIONABLE_DUAL_ROLLER_SHUTTER,
]
@pytest.fixture(autouse=True)
def fixture_platforms() -> Generator[None]:
"""Limit platforms to cover only."""
with patch("homeassistant.components.overkiz.PLATFORMS", [Platform.COVER]):
yield
@pytest.mark.parametrize(
"device",
SNAPSHOT_FIXTURES,
ids=[Path(device.fixture).name for device in SNAPSHOT_FIXTURES],
)
async def test_cover_entities_snapshot(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
device: FixtureDevice,
) -> None:
"""Test representative real setups via snapshot."""
config_entry = await setup_overkiz_integration(fixture=device.fixture)
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
@pytest.mark.parametrize(
("device", "service", "command_name", "parameters", "expected_state"),
[
(SHUTTER, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(AWNING, SERVICE_OPEN_COVER, "deploy", None, CoverState.OPENING),
(GARAGE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(DISCRETE_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(DYNAMIC_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(DYNAMIC_GARAGE_DOOR_OGP, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(DYNAMIC_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(RTS_GATE_4T, SERVICE_OPEN_COVER, "cycle", [0], CoverState.OPENING),
(RTS_GARAGE_DOOR_4T, SERVICE_OPEN_COVER, "cycle", [0], CoverState.OPENING),
(CYCLIC_GARAGE_DOOR, SERVICE_OPEN_COVER, "cycle", None, CoverState.OPENING),
(CYCLIC_SWINGING_GATE, SERVICE_OPEN_COVER, "cycle", None, CoverState.OPENING),
(SLIDING_DISCRETE_GATE, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(PARTIAL_GARAGE_DOOR, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(DYNAMIC_PERGOLA, SERVICE_OPEN_COVER, "open", None, CoverState.OPENING),
(
PERGOLA_HORIZONTAL_AWNING,
SERVICE_OPEN_COVER,
"deploy",
None,
CoverState.OPENING,
),
(
UP_DOWN_BIOCLIMATIC_PERGOLA,
SERVICE_OPEN_COVER,
"open",
[0],
CoverState.OPENING,
),
(TILT_ONLY_VENETIAN_BLIND, SERVICE_OPEN_COVER, "open", [0], CoverState.OPENING),
(UP_DOWN_VENETIAN_BLIND, SERVICE_OPEN_COVER, "open", [0], CoverState.OPENING),
(UP_DOWN_SHEER_SCREEN, SERVICE_OPEN_COVER, "open", [0], CoverState.OPENING),
(
DYNAMIC_VENETIAN_BLIND,
SERVICE_OPEN_COVER,
"open",
None,
CoverState.OPENING,
),
(SHUTTER, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
(AWNING, SERVICE_CLOSE_COVER, "undeploy", None, CoverState.CLOSING),
(GARAGE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
(DISCRETE_GARAGE_DOOR, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
(DYNAMIC_GARAGE_DOOR, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
(
DYNAMIC_GARAGE_DOOR_OGP,
SERVICE_CLOSE_COVER,
"close",
None,
CoverState.CLOSING,
),
(DYNAMIC_GATE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
# Cycle command is used for both open and close; device reports OPENING
# since the RTS protocol has no directional feedback.
(RTS_GATE_4T, SERVICE_CLOSE_COVER, "cycle", [0], CoverState.OPENING),
(RTS_GARAGE_DOOR_4T, SERVICE_CLOSE_COVER, "cycle", [0], CoverState.OPENING),
(CYCLIC_GARAGE_DOOR, SERVICE_CLOSE_COVER, "cycle", None, CoverState.OPENING),
(CYCLIC_SWINGING_GATE, SERVICE_CLOSE_COVER, "cycle", None, CoverState.OPENING),
(SLIDING_DISCRETE_GATE, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
(PARTIAL_GARAGE_DOOR, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
(DYNAMIC_PERGOLA, SERVICE_CLOSE_COVER, "close", None, CoverState.CLOSING),
(
PERGOLA_HORIZONTAL_AWNING,
SERVICE_CLOSE_COVER,
"undeploy",
None,
CoverState.CLOSING,
),
(
UP_DOWN_BIOCLIMATIC_PERGOLA,
SERVICE_CLOSE_COVER,
"close",
[0],
CoverState.CLOSING,
),
(
TILT_ONLY_VENETIAN_BLIND,
SERVICE_CLOSE_COVER,
"close",
[0],
CoverState.CLOSING,
),
(UP_DOWN_VENETIAN_BLIND, SERVICE_CLOSE_COVER, "close", [0], CoverState.CLOSING),
(UP_DOWN_SHEER_SCREEN, SERVICE_CLOSE_COVER, "close", [0], CoverState.CLOSING),
(
DYNAMIC_VENETIAN_BLIND,
SERVICE_CLOSE_COVER,
"close",
None,
CoverState.CLOSING,
),
(SHUTTER, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED),
(AWNING, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED),
(GARAGE, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED),
(DISCRETE_GARAGE_DOOR, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED),
(DYNAMIC_GARAGE_DOOR, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED),
(DYNAMIC_GARAGE_DOOR_OGP, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED),
(DYNAMIC_GATE, SERVICE_STOP_COVER, "stop", None, CoverState.OPEN),
(SLIDING_DISCRETE_GATE, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED),
(PARTIAL_GARAGE_DOOR, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED),
(DYNAMIC_PERGOLA, SERVICE_STOP_COVER, "stop", None, CoverState.CLOSED),
(
PERGOLA_HORIZONTAL_AWNING,
SERVICE_STOP_COVER,
"stop",
None,
CoverState.OPEN,
),
(
UP_DOWN_BIOCLIMATIC_PERGOLA,
SERVICE_STOP_COVER,
"stop",
[0],
STATE_UNKNOWN,
),
(TILT_ONLY_VENETIAN_BLIND, SERVICE_STOP_COVER, "stop", [0], STATE_UNKNOWN),
(
DYNAMIC_VENETIAN_BLIND,
SERVICE_STOP_COVER,
"stop",
None,
CoverState.CLOSED,
),
(
TILT_ONLY_VENETIAN_BLIND,
SERVICE_OPEN_COVER_TILT,
"tiltPositive",
[1, 0],
CoverState.OPENING,
),
(
TILT_ONLY_VENETIAN_BLIND,
SERVICE_CLOSE_COVER_TILT,
"tiltNegative",
[1, 0],
CoverState.CLOSING,
),
(
TILT_ONLY_VENETIAN_BLIND,
SERVICE_STOP_COVER_TILT,
"stop",
[0],
STATE_UNKNOWN,
),
(UP_DOWN_VENETIAN_BLIND, SERVICE_STOP_COVER, "stop", [0], STATE_UNKNOWN),
(UP_DOWN_SHEER_SCREEN, SERVICE_STOP_COVER, "stop", [0], STATE_UNKNOWN),
(
UP_DOWN_VENETIAN_BLIND,
SERVICE_OPEN_COVER_TILT,
"tiltPositive",
[15, 1],
CoverState.OPENING,
),
(
UP_DOWN_VENETIAN_BLIND,
SERVICE_CLOSE_COVER_TILT,
"tiltNegative",
[15, 1],
CoverState.CLOSING,
),
(
UP_DOWN_VENETIAN_BLIND,
SERVICE_STOP_COVER_TILT,
"stop",
[0],
STATE_UNKNOWN,
),
(
UP_DOWN_SHEER_SCREEN,
SERVICE_OPEN_COVER_TILT,
"tiltPositive",
[15, 1],
CoverState.OPENING,
),
(
UP_DOWN_SHEER_SCREEN,
SERVICE_CLOSE_COVER_TILT,
"tiltNegative",
[15, 1],
CoverState.CLOSING,
),
(
UP_DOWN_SHEER_SCREEN,
SERVICE_STOP_COVER_TILT,
"stop",
[0],
STATE_UNKNOWN,
),
],
ids=[
"open-roller-shutter",
"open-awning",
"open-garage-door",
"open-discrete-garage-door",
"open-dynamic-garage-door",
"open-dynamic-garage-door-ogp",
"open-dynamic-gate",
"open-rts-gate-4t",
"open-rts-garage-door-4t",
"open-cyclic-garage-door",
"open-cyclic-swinging-gate",
"open-sliding-discrete-gate",
"open-partial-garage-door",
"open-dynamic-pergola",
"open-pergola-horizontal-awning",
"open-up-down-bioclimatic-pergola",
"open-tilt-only-venetian-blind",
"open-venetian-blind-rts",
"open-sheer-screen-rts",
"open-dynamic-venetian-blind",
"close-roller-shutter",
"close-awning",
"close-garage-door",
"close-discrete-garage-door",
"close-dynamic-garage-door",
"close-dynamic-garage-door-ogp",
"close-dynamic-gate",
"close-rts-gate-4t",
"close-rts-garage-door-4t",
"close-cyclic-garage-door",
"close-cyclic-swinging-gate",
"close-sliding-discrete-gate",
"close-partial-garage-door",
"close-dynamic-pergola",
"close-pergola-horizontal-awning",
"close-up-down-bioclimatic-pergola",
"close-tilt-only-venetian-blind",
"close-venetian-blind-rts",
"close-sheer-screen-rts",
"close-dynamic-venetian-blind",
"stop-roller-shutter",
"stop-awning",
"stop-garage-door",
"stop-discrete-garage-door",
"stop-dynamic-garage-door",
"stop-dynamic-garage-door-ogp",
"stop-dynamic-gate",
"stop-sliding-discrete-gate",
"stop-partial-garage-door",
"stop-dynamic-pergola",
"stop-pergola-horizontal-awning",
"stop-up-down-bioclimatic-pergola",
"stop-tilt-only-venetian-blind",
"stop-dynamic-venetian-blind",
"open-tilt-tilt-only-venetian-blind",
"close-tilt-tilt-only-venetian-blind",
"stop-tilt-tilt-only-venetian-blind",
"stop-venetian-blind-rts",
"stop-sheer-screen-rts",
"open-tilt-venetian-blind-rts",
"close-tilt-venetian-blind-rts",
"stop-tilt-venetian-blind-rts",
"open-tilt-sheer-screen-rts",
"close-tilt-sheer-screen-rts",
"stop-tilt-sheer-screen-rts",
],
)
async def test_cover_service_actions(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
device: FixtureDevice,
service: str,
command_name: str,
parameters: list[Any] | None,
expected_state: CoverState | str,
) -> None:
"""Test open, close, and stop cover and tilt services."""
await setup_overkiz_integration(fixture=device.fixture)
await hass.services.async_call(
COVER_DOMAIN,
service,
{ATTR_ENTITY_ID: device.entity_id},
blocking=True,
)
await hass.async_block_till_done()
assert hass.states.get(device.entity_id).state == expected_state
assert_command_call(
mock_client,
device_url=device.device_url,
command_name=command_name,
parameters=parameters,
)
@pytest.mark.parametrize(
("device", "entity_id", "command_name", "parameters", "position"),
[
(SHUTTER, SHUTTER.entity_id, "setClosure", [75], 25),
(AWNING, AWNING.entity_id, "setDeployment", [80], 80),
(
LOW_SPEED,
"cover.maple_residence_nursery_shutter_low_speed",
"setClosureAndLinearSpeed",
[65, OverkizCommandParam.LOWSPEED],
35,
),
(DYNAMIC_PERGOLA, DYNAMIC_PERGOLA.entity_id, "setClosure", [60], 40),
(
PERGOLA_HORIZONTAL_AWNING,
PERGOLA_HORIZONTAL_AWNING.entity_id,
"setDeployment",
[80],
80,
),
(
DYNAMIC_VENETIAN_BLIND,
DYNAMIC_VENETIAN_BLIND.entity_id,
"setClosure",
[75],
25,
),
],
ids=[
"roller-shutter",
"awning",
"low-speed",
"dynamic-pergola",
"pergola-horizontal-awning",
"dynamic-venetian-blind",
],
)
async def test_cover_set_position(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
device: FixtureDevice,
entity_id: str,
command_name: str,
parameters: list[Any],
position: int,
) -> None:
"""Test cover position services and mapping."""
await setup_overkiz_integration(fixture=device.fixture)
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{ATTR_ENTITY_ID: entity_id, ATTR_POSITION: position},
blocking=True,
)
assert_command_call(
mock_client,
device_url=device.device_url,
command_name=command_name,
parameters=parameters,
)
@pytest.mark.parametrize(
("device", "command_name", "parameters", "tilt_position"),
[
(PERGOLA, "setOrientation", [60], 40),
(DYNAMIC_PERGOLA_TILT_ONLY, "setOrientation", [60], 40),
],
ids=[
"bioclimatic-pergola",
"dynamic-pergola-tilt-only",
],
)
async def test_cover_set_tilt_position(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
device: FixtureDevice,
command_name: str,
parameters: list[Any],
tilt_position: int,
) -> None:
"""Test cover tilt position services and mapping."""
await setup_overkiz_integration(fixture=device.fixture)
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_TILT_POSITION,
{ATTR_ENTITY_ID: device.entity_id, ATTR_TILT_POSITION: tilt_position},
blocking=True,
)
assert_command_call(
mock_client,
device_url=device.device_url,
command_name=command_name,
parameters=parameters,
)
async def test_is_closed_falls_back_to_position(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test is_closed derives from position when OpenClosedState is absent."""
await setup_overkiz_integration(fixture=POSITIONABLE_VENETIAN_BLIND.fixture)
state = hass.states.get(POSITIONABLE_VENETIAN_BLIND.entity_id)
assert state.state == CoverState.CLOSED
assert state.attributes[ATTR_CURRENT_POSITION] == 0
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_STATE_CHANGED.value,
device_url=POSITIONABLE_VENETIAN_BLIND.device_url,
device_states=[
{
"name": OverkizState.CORE_CLOSURE.value,
"type": 1,
"value": 50,
},
],
)
],
)
state = hass.states.get(POSITIONABLE_VENETIAN_BLIND.entity_id)
assert state.state == CoverState.OPEN
assert state.attributes[ATTR_CURRENT_POSITION] == 50
async def test_cover_tilt_services(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test tilt services for a pergola from a full user setup."""
await setup_overkiz_integration(fixture=PERGOLA.fixture)
state = hass.states.get(PERGOLA.entity_id)
assert state
assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0
assert ATTR_CURRENT_POSITION not in state.attributes
assert state.attributes["supported_features"] == (
CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT
| CoverEntityFeature.SET_TILT_POSITION
)
mock_client.execute_command.reset_mock()
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_OPEN_COVER_TILT,
{ATTR_ENTITY_ID: PERGOLA.entity_id},
blocking=True,
)
await hass.async_block_till_done()
assert hass.states.get(PERGOLA.entity_id).state == CoverState.OPENING
assert_command_call(
mock_client,
device_url=PERGOLA.device_url,
command_name="openSlats",
)
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.EXECUTION_STATE_CHANGED.value,
device_url=PERGOLA.device_url,
exec_id="exec-1",
new_state=ExecutionState.COMPLETED.value,
)
],
)
assert hass.states.get(PERGOLA.entity_id).state == CoverState.CLOSED
mock_client.execute_command.reset_mock()
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_CLOSE_COVER_TILT,
{ATTR_ENTITY_ID: PERGOLA.entity_id},
blocking=True,
)
await hass.async_block_till_done()
assert hass.states.get(PERGOLA.entity_id).state == CoverState.CLOSING
assert_command_call(
mock_client,
device_url=PERGOLA.device_url,
command_name="closeSlats",
)
mock_client.execute_command.reset_mock()
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_STOP_COVER_TILT,
{ATTR_ENTITY_ID: PERGOLA.entity_id},
blocking=True,
)
assert_command_call(
mock_client,
device_url=PERGOLA.device_url,
command_name="stop",
)
mock_client.execute_command.reset_mock()
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_TILT_POSITION,
{ATTR_ENTITY_ID: PERGOLA.entity_id, ATTR_TILT_POSITION: 40},
blocking=True,
)
assert_command_call(
mock_client,
device_url=PERGOLA.device_url,
command_name="setOrientation",
parameters=[60],
)
async def test_cover_state_updates(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test cover state updates via events and execution tracking."""
await setup_overkiz_integration(fixture=SHUTTER.fixture)
assert hass.states.get(SHUTTER.entity_id).attributes[ATTR_CURRENT_POSITION] == 0
# Position update via device state change event
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_STATE_CHANGED.value,
device_url=SHUTTER.device_url,
device_states=[
{
"name": OverkizState.CORE_CLOSURE.value,
"type": 1,
"value": 0,
},
{
"name": OverkizState.CORE_TARGET_CLOSURE.value,
"type": 1,
"value": 0,
},
{
"name": OverkizState.CORE_MOVING.value,
"type": 6,
"value": False,
},
{
"name": OverkizState.CORE_OPEN_CLOSED.value,
"type": 3,
"value": OverkizCommandParam.OPEN.value,
},
],
)
],
)
state = hass.states.get(SHUTTER.entity_id)
assert state.attributes[ATTR_CURRENT_POSITION] == 100
assert state.state == CoverState.OPEN
# Position update to closed
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_STATE_CHANGED.value,
device_url=SHUTTER.device_url,
device_states=[
{
"name": OverkizState.CORE_CLOSURE.value,
"type": 1,
"value": 100,
},
{
"name": OverkizState.CORE_TARGET_CLOSURE.value,
"type": 1,
"value": 100,
},
{
"name": OverkizState.CORE_MOVING.value,
"type": 6,
"value": False,
},
{
"name": OverkizState.CORE_OPEN_CLOSED.value,
"type": 3,
"value": OverkizCommandParam.CLOSED.value,
},
],
)
],
)
state = hass.states.get(SHUTTER.entity_id)
assert state.attributes[ATTR_CURRENT_POSITION] == 0
assert state.state == CoverState.CLOSED
# Execution tracking: state stays OPENING until execution completes
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_OPEN_COVER,
{ATTR_ENTITY_ID: SHUTTER.entity_id},
blocking=True,
)
await hass.async_block_till_done()
assert hass.states.get(SHUTTER.entity_id).state == CoverState.OPENING
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_STATE_CHANGED.value,
device_url=SHUTTER.device_url,
device_states=[
{
"name": OverkizState.CORE_CLOSURE.value,
"type": 1,
"value": 0,
},
{
"name": OverkizState.CORE_TARGET_CLOSURE.value,
"type": 1,
"value": 0,
},
{
"name": OverkizState.CORE_MOVING.value,
"type": 6,
"value": False,
},
{
"name": OverkizState.CORE_OPEN_CLOSED.value,
"type": 3,
"value": OverkizCommandParam.OPEN.value,
},
],
)
],
)
assert hass.states.get(SHUTTER.entity_id).state == CoverState.OPENING
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.EXECUTION_STATE_CHANGED.value,
device_url=SHUTTER.device_url,
exec_id="exec-1",
new_state=ExecutionState.COMPLETED.value,
)
],
)
assert hass.states.get(SHUTTER.entity_id).state == CoverState.OPEN
# Unavailability propagates to entity state
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_UNAVAILABLE.value, device_url=SHUTTER.device_url
)
],
)
assert hass.states.get(SHUTTER.entity_id).state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("device_states", "expected_state"),
[
(
[
{"name": OverkizState.CORE_MOVING.value, "type": 6, "value": True},
{"name": OverkizState.CORE_CLOSURE.value, "type": 1, "value": 75},
{
"name": OverkizState.CORE_TARGET_CLOSURE.value,
"type": 1,
"value": 20,
},
],
CoverState.OPENING,
),
(
[
{"name": OverkizState.CORE_MOVING.value, "type": 6, "value": True},
{"name": OverkizState.CORE_CLOSURE.value, "type": 1, "value": 20},
{
"name": OverkizState.CORE_TARGET_CLOSURE.value,
"type": 1,
"value": 75,
},
],
CoverState.CLOSING,
),
],
ids=["opening", "closing"],
)
async def test_vertical_cover_moving_direction(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
freezer: FrozenDateTimeFactory,
device_states: list[dict[str, Any]],
expected_state: CoverState,
) -> None:
"""Test moving direction detection for vertical covers."""
await setup_overkiz_integration(fixture=SHUTTER.fixture)
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_STATE_CHANGED.value,
device_url=SHUTTER.device_url,
device_states=device_states,
)
],
)
assert hass.states.get(SHUTTER.entity_id).state == expected_state
@pytest.mark.parametrize(
("device_states", "expected_state"),
[
(
[
{"name": OverkizState.CORE_MOVING.value, "type": 6, "value": True},
{
"name": OverkizState.CORE_DEPLOYMENT.value,
"type": 1,
"value": 20,
},
{
"name": OverkizState.CORE_TARGET_CLOSURE.value,
"type": 1,
"value": 80,
},
],
CoverState.OPENING,
),
(
[
{"name": OverkizState.CORE_MOVING.value, "type": 6, "value": True},
{
"name": OverkizState.CORE_DEPLOYMENT.value,
"type": 1,
"value": 80,
},
{
"name": OverkizState.CORE_TARGET_CLOSURE.value,
"type": 1,
"value": 20,
},
],
CoverState.CLOSING,
),
],
ids=["opening", "closing"],
)
async def test_awning_moving_direction(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
freezer: FrozenDateTimeFactory,
device_states: list[dict[str, Any]],
expected_state: CoverState,
) -> None:
"""Test moving direction detection for awnings."""
await setup_overkiz_integration(fixture=AWNING.fixture)
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_STATE_CHANGED.value,
device_url=AWNING.device_url,
device_states=device_states,
)
],
)
assert hass.states.get(AWNING.entity_id).state == expected_state
async def test_awning_direct_position_mapping(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test awning deployment uses direct mapping while vertical covers invert."""
await setup_overkiz_integration(fixture=AWNING.fixture)
assert hass.states.get(AWNING.entity_id).attributes[ATTR_CURRENT_POSITION] == 0
mock_client.execute_command.reset_mock()
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{ATTR_ENTITY_ID: AWNING.entity_id, ATTR_POSITION: 35},
blocking=True,
)
assert_command_call(
mock_client,
device_url=AWNING.device_url,
command_name="setDeployment",
parameters=[35],
)
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_STATE_CHANGED.value,
device_url=AWNING.device_url,
device_states=[
{
"name": OverkizState.CORE_DEPLOYMENT.value,
"type": 1,
"value": 100,
}
],
)
],
)
assert hass.states.get(AWNING.entity_id).attributes[ATTR_CURRENT_POSITION] == 100
async def test_moving_offset_missing_closure_states(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test is_opening/is_closing None when states missing."""
await setup_overkiz_integration(fixture=PERGOLA.fixture)
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_STATE_CHANGED.value,
device_url=PERGOLA.device_url,
device_states=[
{
"name": OverkizState.CORE_MOVING.value,
"type": 6,
"value": True,
},
],
)
],
)
state = hass.states.get(PERGOLA.entity_id)
assert state.state == CoverState.CLOSED
async def test_moving_offset_none_values(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that is_opening/is_closing return None when closure value_as_int is None."""
await setup_overkiz_integration(fixture=SHUTTER.fixture)
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_STATE_CHANGED.value,
device_url=SHUTTER.device_url,
device_states=[
{
"name": OverkizState.CORE_MOVING.value,
"type": 6,
"value": True,
},
{
"name": OverkizState.CORE_CLOSURE.value,
"type": 1,
"value": None,
},
{
"name": OverkizState.CORE_TARGET_CLOSURE.value,
"type": 1,
"value": 50,
},
{
"name": OverkizState.CORE_OPEN_CLOSED.value,
"type": 3,
"value": OverkizCommandParam.OPEN.value,
},
],
)
],
)
state = hass.states.get(SHUTTER.entity_id)
assert state.state == CoverState.OPEN
async def test_tilt_position_none_value(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that tilt position returns None when value_as_int is None."""
await setup_overkiz_integration(fixture=PERGOLA.fixture)
await async_deliver_events(
hass,
freezer,
mock_client,
[
build_event(
EventName.DEVICE_STATE_CHANGED.value,
device_url=PERGOLA.device_url,
device_states=[
{
"name": OverkizState.CORE_SLATE_ORIENTATION.value,
"type": 1,
"value": None,
},
],
)
],
)
state = hass.states.get(PERGOLA.entity_id)
assert ATTR_CURRENT_TILT_POSITION not in state.attributes
async def test_low_speed_cover_open_close(
hass: HomeAssistant,
setup_overkiz_integration: SetupOverkizIntegration,
mock_client: MockOverkizClient,
) -> None:
"""Test low speed cover open and close send correct commands."""
await setup_overkiz_integration(fixture=LOW_SPEED.fixture)
entity_id = "cover.maple_residence_nursery_shutter_low_speed"
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_OPEN_COVER,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert_command_call(
mock_client,
device_url=LOW_SPEED.device_url,
command_name="setClosureAndLinearSpeed",
parameters=[0, OverkizCommandParam.LOWSPEED],
)
mock_client.execute_command.reset_mock()
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_CLOSE_COVER,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert_command_call(
mock_client,
device_url=LOW_SPEED.device_url,
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:
"""Error 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