From 5dc1a321dd8623efc681ff9781a6c93e54c76276 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Mar 2025 16:14:09 -1000 Subject: [PATCH] Rework cover reproduce_state to consider supported features (#140558) * Handle open/closed state in reproduce_state for tilt only covers fixes #137144 * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * cleanups * rework * rework * rework * rework * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * more coverage * back compat * back compat * back compat * cleanups * cleanups * cleanups * cleanups * comments * comments --- .../components/cover/reproduce_state.py | 256 ++++++++--- .../components/cover/test_reproduce_state.py | 407 ++++++++++++++++-- 2 files changed, 570 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py index 307fe5f11bd..de3e0cebfb7 100644 --- a/homeassistant/components/cover/reproduce_state.py +++ b/homeassistant/components/cover/reproduce_state.py @@ -3,12 +3,14 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Coroutine, Iterable +from functools import partial import logging -from typing import Any +from typing import Any, Final from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -16,7 +18,8 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, ) -from homeassistant.core import Context, HomeAssistant, State +from homeassistant.core import Context, HomeAssistant, ServiceResponse, State +from homeassistant.util.enum import try_parse_enum from . import ( ATTR_CURRENT_POSITION, @@ -24,17 +27,140 @@ from . import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, + CoverEntityFeature, CoverState, ) _LOGGER = logging.getLogger(__name__) -VALID_STATES = { - CoverState.CLOSED, - CoverState.CLOSING, - CoverState.OPEN, - CoverState.OPENING, -} + +OPENING_STATES = {CoverState.OPENING, CoverState.OPEN} +CLOSING_STATES = {CoverState.CLOSING, CoverState.CLOSED} +VALID_STATES: set[CoverState] = OPENING_STATES | CLOSING_STATES + +FULL_OPEN: Final = 100 +FULL_CLOSE: Final = 0 + + +def _determine_features(current_attrs: dict[str, Any]) -> CoverEntityFeature: + """Determine supported features based on current attributes.""" + features = CoverEntityFeature(0) + if ATTR_CURRENT_POSITION in current_attrs: + features |= ( + CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + ) + if ATTR_CURRENT_TILT_POSITION in current_attrs: + features |= ( + CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + ) + if features == CoverEntityFeature(0): + features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + return features + + +async def _async_set_position( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + target_position: int, +) -> bool: + """Set the position of the cover. + + Returns True if the position was set, False if there is no + supported method for setting the position. + """ + if target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features: + await service_call(SERVICE_CLOSE_COVER, service_data) + elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features: + await service_call(SERVICE_OPEN_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: target_position} + ) + else: + # Requested a position but the cover doesn't support it + return False + return True + + +async def _async_set_tilt_position( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + target_tilt_position: int, +) -> bool: + """Set the tilt position of the cover. + + Returns True if the tilt position was set, False if there is no + supported method for setting the tilt position. + """ + if target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features: + await service_call(SERVICE_CLOSE_COVER_TILT, service_data) + elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features: + await service_call(SERVICE_OPEN_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: target_tilt_position}, + ) + else: + # Requested a tilt position but the cover doesn't support it + return False + return True + + +async def _async_close_cover( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + set_position: bool, + set_tilt: bool, +) -> None: + """Close the cover if it was not closed by setting the position.""" + if not set_position: + if CoverEntityFeature.CLOSE in features: + await service_call(SERVICE_CLOSE_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: FULL_CLOSE} + ) + if not set_tilt: + if CoverEntityFeature.CLOSE_TILT in features: + await service_call(SERVICE_CLOSE_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: FULL_CLOSE}, + ) + + +async def _async_open_cover( + service_call: partial[Coroutine[Any, Any, ServiceResponse]], + service_data: dict[str, Any], + features: CoverEntityFeature, + set_position: bool, + set_tilt: bool, +) -> None: + """Open the cover if it was not opened by setting the position.""" + if not set_position: + if CoverEntityFeature.OPEN in features: + await service_call(SERVICE_OPEN_COVER, service_data) + elif CoverEntityFeature.SET_POSITION in features: + await service_call( + SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: FULL_OPEN} + ) + if not set_tilt: + if CoverEntityFeature.OPEN_TILT in features: + await service_call(SERVICE_OPEN_COVER_TILT, service_data) + elif CoverEntityFeature.SET_TILT_POSITION in features: + await service_call( + SERVICE_SET_COVER_TILT_POSITION, + service_data | {ATTR_TILT_POSITION: FULL_OPEN}, + ) async def _async_reproduce_state( @@ -45,74 +171,72 @@ async def _async_reproduce_state( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce a single state.""" - if (cur_state := hass.states.get(state.entity_id)) is None: - _LOGGER.warning("Unable to find entity %s", state.entity_id) + entity_id = state.entity_id + if (cur_state := hass.states.get(entity_id)) is None: + _LOGGER.warning("Unable to find entity %s", entity_id) return - if state.state not in VALID_STATES: - _LOGGER.warning( - "Invalid state specified for %s: %s", state.entity_id, state.state - ) + if (target_state := state.state) not in VALID_STATES: + _LOGGER.warning("Invalid state specified for %s: %s", entity_id, target_state) return + current_attrs = cur_state.attributes + target_attrs = state.attributes + + current_position = current_attrs.get(ATTR_CURRENT_POSITION) + target_position = target_attrs.get(ATTR_CURRENT_POSITION) + position_matches = current_position == target_position + + current_tilt_position = current_attrs.get(ATTR_CURRENT_TILT_POSITION) + target_tilt_position = target_attrs.get(ATTR_CURRENT_TILT_POSITION) + tilt_position_matches = current_tilt_position == target_tilt_position + + state_matches = cur_state.state == target_state # Return if we are already at the right state. - if ( - cur_state.state == state.state - and cur_state.attributes.get(ATTR_CURRENT_POSITION) - == state.attributes.get(ATTR_CURRENT_POSITION) - and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) - == state.attributes.get(ATTR_CURRENT_TILT_POSITION) - ): + if state_matches and position_matches and tilt_position_matches: return - service_data = {ATTR_ENTITY_ID: state.entity_id} - service_data_tilting = {ATTR_ENTITY_ID: state.entity_id} + features = try_parse_enum( + CoverEntityFeature, current_attrs.get(ATTR_SUPPORTED_FEATURES) + ) + if features is None: + # Backwards compatibility for integrations that + # don't set supported features since it previously + # worked without it. + _LOGGER.warning("Supported features is not set for %s", entity_id) + features = _determine_features(current_attrs) - if not ( - cur_state.state == state.state - and cur_state.attributes.get(ATTR_CURRENT_POSITION) - == state.attributes.get(ATTR_CURRENT_POSITION) - ): - # Open/Close - if state.state in [CoverState.CLOSED, CoverState.CLOSING]: - service = SERVICE_CLOSE_COVER - elif state.state in [CoverState.OPEN, CoverState.OPENING]: - if ( - ATTR_CURRENT_POSITION in cur_state.attributes - and ATTR_CURRENT_POSITION in state.attributes - ): - service = SERVICE_SET_COVER_POSITION - service_data[ATTR_POSITION] = state.attributes[ATTR_CURRENT_POSITION] - else: - service = SERVICE_OPEN_COVER + service_call = partial( + hass.services.async_call, + DOMAIN, + context=context, + blocking=True, + ) + service_data = {ATTR_ENTITY_ID: entity_id} - await hass.services.async_call( - DOMAIN, service, service_data, context=context, blocking=True + set_position = ( + not position_matches + and target_position is not None + and await _async_set_position( + service_call, service_data, features, target_position + ) + ) + set_tilt = ( + not tilt_position_matches + and target_tilt_position is not None + and await _async_set_tilt_position( + service_call, service_data, features, target_tilt_position + ) + ) + + if target_state in CLOSING_STATES: + await _async_close_cover( + service_call, service_data, features, set_position, set_tilt ) - if ( - ATTR_CURRENT_TILT_POSITION in state.attributes - and ATTR_CURRENT_TILT_POSITION in cur_state.attributes - and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION) - != state.attributes.get(ATTR_CURRENT_TILT_POSITION) - ): - # Tilt position - if state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100: - service_tilting = SERVICE_OPEN_COVER_TILT - elif state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 0: - service_tilting = SERVICE_CLOSE_COVER_TILT - else: - service_tilting = SERVICE_SET_COVER_TILT_POSITION - service_data_tilting[ATTR_TILT_POSITION] = state.attributes[ - ATTR_CURRENT_TILT_POSITION - ] - - await hass.services.async_call( - DOMAIN, - service_tilting, - service_data_tilting, - context=context, - blocking=True, + elif target_state in OPENING_STATES: + await _async_open_cover( + service_call, service_data, features, set_position, set_tilt ) diff --git a/tests/components/cover/test_reproduce_state.py b/tests/components/cover/test_reproduce_state.py index 4aad27011fa..57fc5aed5e9 100644 --- a/tests/components/cover/test_reproduce_state.py +++ b/tests/components/cover/test_reproduce_state.py @@ -7,9 +7,11 @@ from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, + CoverEntityFeature, CoverState, ) from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -27,35 +29,213 @@ async def test_reproducing_states( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test reproducing Cover states.""" - hass.states.async_set("cover.entity_close", CoverState.CLOSED, {}) + hass.states.async_set( + "cover.entity_close", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_close_open", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.open_only_supports_close_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.open_missing_all_features", + CoverState.OPEN, + ) + hass.states.async_set( + "cover.closed_missing_all_features_has_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + }, + ) + hass.states.async_set( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 50, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_tilt_close_open", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.open_only_supports_tilt_close_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.closed_only_supports_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + }, + ) + hass.states.async_set( + "cover.open_only_supports_position", + CoverState.OPEN, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION}, + ) hass.states.async_set( "cover.entity_close_attr", CoverState.CLOSED, - {ATTR_CURRENT_POSITION: 0, ATTR_CURRENT_TILT_POSITION: 0}, + { + ATTR_CURRENT_POSITION: 0, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( - "cover.entity_close_tilt", CoverState.CLOSED, {ATTR_CURRENT_TILT_POSITION: 50} + "cover.entity_close_tilt", + CoverState.CLOSED, + { + ATTR_CURRENT_TILT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, ) - hass.states.async_set("cover.entity_open", CoverState.OPEN, {}) hass.states.async_set( - "cover.entity_slightly_open", CoverState.OPEN, {ATTR_CURRENT_POSITION: 50} + "cover.entity_open", + CoverState.OPEN, + {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE | CoverEntityFeature.OPEN}, + ) + hass.states.async_set( + "cover.entity_slightly_open", + CoverState.OPEN, + { + ATTR_CURRENT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_open_attr", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 0}, + { + ATTR_CURRENT_POSITION: 100, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_open_tilt", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 50, ATTR_CURRENT_TILT_POSITION: 50}, + { + ATTR_CURRENT_POSITION: 50, + ATTR_CURRENT_TILT_POSITION: 50, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, ) hass.states.async_set( "cover.entity_entirely_open", CoverState.OPEN, - {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 100}, + { + ATTR_CURRENT_POSITION: 100, + ATTR_CURRENT_TILT_POSITION: 100, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN, + }, + ) + hass.states.async_set( + "cover.tilt_only_open", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.tilt_only_closed", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT, + }, + ) + hass.states.async_set( + "cover.tilt_only_tilt_position_100", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 100, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_only_tilt_position_0", + CoverState.CLOSED, + { + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_open_only_supports_tilt_position", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + }, + ) + hass.states.async_set( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + ATTR_CURRENT_TILT_POSITION: 50, + }, + ) + hass.states.async_set( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.CLOSED, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_TILT_POSITION, + }, ) - close_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER) open_calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) close_tilt_calls = async_mock_service(hass, "cover", SERVICE_CLOSE_COVER_TILT) @@ -70,6 +250,31 @@ async def test_reproducing_states( hass, [ State("cover.entity_close", CoverState.CLOSED), + State("cover.closed_only_supports_close_open", CoverState.CLOSED), + State("cover.closed_only_supports_tilt_close_open", CoverState.CLOSED), + State("cover.open_only_supports_close_open", CoverState.OPEN), + State("cover.open_only_supports_tilt_close_open", CoverState.OPEN), + State("cover.open_missing_all_features", CoverState.OPEN), + State( + "cover.closed_missing_all_features_has_position", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + }, + ), + State( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + { + ATTR_CURRENT_TILT_POSITION: 50, + }, + ), + State( + "cover.closed_only_supports_position", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 0}, + ), + State("cover.open_only_supports_position", CoverState.OPEN), State( "cover.entity_close_attr", CoverState.CLOSED, @@ -101,6 +306,39 @@ async def test_reproducing_states( CoverState.OPEN, {ATTR_CURRENT_POSITION: 100, ATTR_CURRENT_TILT_POSITION: 100}, ), + State( + "cover.tilt_only_open", + CoverState.OPEN, + {}, + ), + State( + "cover.tilt_only_tilt_position_100", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 100}, + ), + State( + "cover.tilt_only_closed", + CoverState.CLOSED, + {}, + ), + State( + "cover.tilt_only_tilt_position_0", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 0}, + ), + State( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 50}, + ), + State( + "cover.tilt_open_only_supports_tilt_position", + CoverState.OPEN, + ), + State( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.CLOSED, + ), ], ) @@ -127,6 +365,35 @@ async def test_reproducing_states( hass, [ State("cover.entity_close", CoverState.OPEN), + State( + "cover.closed_only_supports_close_open", + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 100}, + ), + State( + "cover.open_only_supports_close_open", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 50}, + ), + State( + "cover.open_only_supports_tilt_close_open", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 50}, + ), + State("cover.closed_only_supports_tilt_close_open", CoverState.OPEN), + State("cover.open_missing_all_features", CoverState.CLOSED), + State( + "cover.closed_missing_all_features_has_position", + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 70}, + ), + State( + "cover.open_missing_all_features_has_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 20}, + ), + State("cover.closed_only_supports_position", CoverState.OPEN), + State("cover.open_only_supports_position", CoverState.CLOSED), State( "cover.entity_close_attr", CoverState.OPEN, @@ -152,6 +419,39 @@ async def test_reproducing_states( ), # Should not raise State("cover.non_existing", "on"), + State( + "cover.tilt_only_open", + CoverState.CLOSED, + {}, + ), + State( + "cover.tilt_only_tilt_position_100", + CoverState.CLOSED, + {ATTR_CURRENT_TILT_POSITION: 0}, + ), + State( + "cover.tilt_only_closed", + CoverState.OPEN, + {}, + ), + State( + "cover.tilt_only_tilt_position_0", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 100}, + ), + State( + "cover.tilt_partial_open_only_supports_tilt_position", + CoverState.OPEN, + {ATTR_CURRENT_TILT_POSITION: 70}, + ), + State( + "cover.tilt_open_only_supports_tilt_position", + CoverState.CLOSED, + ), + State( + "cover.tilt_closed_only_supports_tilt_position", + CoverState.OPEN, + ), ], ) @@ -159,8 +459,10 @@ async def test_reproducing_states( {"entity_id": "cover.entity_open"}, {"entity_id": "cover.entity_open_attr"}, {"entity_id": "cover.entity_entirely_open"}, + {"entity_id": "cover.open_only_supports_close_open"}, + {"entity_id": "cover.open_missing_all_features"}, ] - assert len(close_calls) == 3 + assert len(close_calls) == len(valid_close_calls) for call in close_calls: assert call.domain == "cover" assert call.data in valid_close_calls @@ -170,8 +472,9 @@ async def test_reproducing_states( {"entity_id": "cover.entity_close"}, {"entity_id": "cover.entity_slightly_open"}, {"entity_id": "cover.entity_open_tilt"}, + {"entity_id": "cover.closed_only_supports_close_open"}, ] - assert len(open_calls) == 3 + assert len(open_calls) == len(valid_open_calls) for call in open_calls: assert call.domain == "cover" assert call.data in valid_open_calls @@ -180,27 +483,77 @@ async def test_reproducing_states( valid_close_tilt_calls = [ {"entity_id": "cover.entity_open_tilt"}, {"entity_id": "cover.entity_entirely_open"}, + {"entity_id": "cover.tilt_only_open"}, + {"entity_id": "cover.entity_open_attr"}, + {"entity_id": "cover.tilt_only_tilt_position_100"}, + {"entity_id": "cover.open_only_supports_tilt_close_open"}, ] - assert len(close_tilt_calls) == 2 + assert len(close_tilt_calls) == len(valid_close_tilt_calls) for call in close_tilt_calls: assert call.domain == "cover" assert call.data in valid_close_tilt_calls valid_close_tilt_calls.remove(call.data) - assert len(open_tilt_calls) == 1 - assert open_tilt_calls[0].domain == "cover" - assert open_tilt_calls[0].data == {"entity_id": "cover.entity_close_tilt"} + valid_open_tilt_calls = [ + {"entity_id": "cover.entity_close_tilt"}, + {"entity_id": "cover.tilt_only_closed"}, + {"entity_id": "cover.tilt_only_tilt_position_0"}, + {"entity_id": "cover.closed_only_supports_tilt_close_open"}, + ] + assert len(open_tilt_calls) == len(valid_open_tilt_calls) + for call in open_tilt_calls: + assert call.domain == "cover" + assert call.data in valid_open_tilt_calls + valid_open_tilt_calls.remove(call.data) - assert len(position_calls) == 1 - assert position_calls[0].domain == "cover" - assert position_calls[0].data == { - "entity_id": "cover.entity_close_attr", - ATTR_POSITION: 50, - } + valid_position_calls = [ + { + "entity_id": "cover.entity_close_attr", + ATTR_POSITION: 50, + }, + { + "entity_id": "cover.closed_missing_all_features_has_position", + ATTR_POSITION: 70, + }, + { + "entity_id": "cover.closed_only_supports_position", + ATTR_POSITION: 100, + }, + { + "entity_id": "cover.open_only_supports_position", + ATTR_POSITION: 0, + }, + ] + assert len(position_calls) == len(valid_position_calls) + for call in position_calls: + assert call.domain == "cover" + assert call.data in valid_position_calls + valid_position_calls.remove(call.data) - assert len(position_tilt_calls) == 1 - assert position_tilt_calls[0].domain == "cover" - assert position_tilt_calls[0].data == { - "entity_id": "cover.entity_close_attr", - ATTR_TILT_POSITION: 50, - } + valid_position_tilt_calls = [ + { + "entity_id": "cover.entity_close_attr", + ATTR_TILT_POSITION: 50, + }, + { + "entity_id": "cover.open_missing_all_features_has_tilt_position", + ATTR_TILT_POSITION: 20, + }, + { + "entity_id": "cover.tilt_open_only_supports_tilt_position", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.tilt_closed_only_supports_tilt_position", + ATTR_TILT_POSITION: 100, + }, + { + "entity_id": "cover.tilt_partial_open_only_supports_tilt_position", + ATTR_TILT_POSITION: 70, + }, + ] + assert len(position_tilt_calls) == len(valid_position_tilt_calls) + for call in position_tilt_calls: + assert call.domain == "cover" + assert call.data in valid_position_tilt_calls + valid_position_tilt_calls.remove(call.data)