diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 55c26b9499d..b8d21c0c77b 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -68,7 +68,6 @@ from .const import ( ERR_ALREADY_ARMED, ERR_ALREADY_DISARMED, ERR_CHALLENGE_NOT_SETUP, - ERR_FUNCTION_NOT_SUPPORTED, ERR_NOT_SUPPORTED, ERR_UNSUPPORTED_INPUT, ERR_VALUE_OUT_OF_RANGE, @@ -120,6 +119,7 @@ COMMAND_INPUT = f"{PREFIX_COMMANDS}SetInput" COMMAND_NEXT_INPUT = f"{PREFIX_COMMANDS}NextInput" COMMAND_PREVIOUS_INPUT = f"{PREFIX_COMMANDS}PreviousInput" COMMAND_OPENCLOSE = f"{PREFIX_COMMANDS}OpenClose" +COMMAND_OPENCLOSE_RELATIVE = f"{PREFIX_COMMANDS}OpenCloseRelative" COMMAND_SET_VOLUME = f"{PREFIX_COMMANDS}setVolume" COMMAND_VOLUME_RELATIVE = f"{PREFIX_COMMANDS}volumeRelative" COMMAND_MUTE = f"{PREFIX_COMMANDS}mute" @@ -1519,7 +1519,7 @@ class OpenCloseTrait(_Trait): ) name = TRAIT_OPENCLOSE - commands = [COMMAND_OPENCLOSE] + commands = [COMMAND_OPENCLOSE, COMMAND_OPENCLOSE_RELATIVE] @staticmethod def supported(domain, features, device_class): @@ -1543,9 +1543,20 @@ class OpenCloseTrait(_Trait): def sync_attributes(self): """Return opening direction.""" response = {} + features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if self.state.domain == binary_sensor.DOMAIN: response["queryOnlyOpenClose"] = True response["discreteOnlyOpenClose"] = True + elif self.state.domain == cover.DOMAIN: + if features & cover.SUPPORT_SET_POSITION == 0: + response["discreteOnlyOpenClose"] = True + + if ( + features & cover.SUPPORT_OPEN == 0 + and features & cover.SUPPORT_CLOSE == 0 + ): + response["queryOnlyOpenClose"] = True if self.state.attributes.get(ATTR_ASSUMED_STATE): response["commandOnlyOpenClose"] = True @@ -1590,26 +1601,36 @@ class OpenCloseTrait(_Trait): async def execute(self, command, data, params, challenge): """Execute an Open, close, Set position command.""" domain = self.state.domain + features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if domain == cover.DOMAIN: svc_params = {ATTR_ENTITY_ID: self.state.entity_id} + should_verify = False + if command == COMMAND_OPENCLOSE_RELATIVE: + position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + if position is None: + raise SmartHomeError( + ERR_NOT_SUPPORTED, + "Current position not know for relative command", + ) + position = max(0, min(100, position + params["openRelativePercent"])) + else: + position = params["openPercent"] - if params["openPercent"] == 0: + if features & cover.SUPPORT_SET_POSITION: + service = cover.SERVICE_SET_COVER_POSITION + if position > 0: + should_verify = True + svc_params[cover.ATTR_POSITION] = position + elif position == 0: service = cover.SERVICE_CLOSE_COVER should_verify = False - elif params["openPercent"] == 100: + elif position == 100: service = cover.SERVICE_OPEN_COVER should_verify = True - elif ( - self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & cover.SUPPORT_SET_POSITION - ): - service = cover.SERVICE_SET_COVER_POSITION - should_verify = True - svc_params[cover.ATTR_POSITION] = params["openPercent"] else: raise SmartHomeError( - ERR_FUNCTION_NOT_SUPPORTED, "Setting a position is not supported" + ERR_NOT_SUPPORTED, "No support for partial open close" ) if ( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index f35415ee9e4..3fcab2dc2a2 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -839,7 +839,7 @@ async def test_device_class_cover(hass, device_class, google_type): "agentUserId": "test-agent", "devices": [ { - "attributes": {}, + "attributes": {"discreteOnlyOpenClose": True}, "id": "cover.demo_sensor", "name": {"name": "Demo Sensor"}, "traits": ["action.devices.traits.OpenClose"], diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index a579eebea04..2ca2e6c8e6c 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1860,8 +1860,12 @@ async def test_openclose_cover(hass): calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 50}, {}) - assert len(calls) == 1 + await trt.execute( + trait.COMMAND_OPENCLOSE_RELATIVE, BASIC_DATA, {"openRelativePercent": 50}, {} + ) + assert len(calls) == 2 assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 50} + assert calls[1].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 100} async def test_openclose_cover_unknown_state(hass): @@ -1873,10 +1877,14 @@ async def test_openclose_cover_unknown_state(hass): # No state trt = trait.OpenCloseTrait( - hass, State("cover.bla", STATE_UNKNOWN, {}), BASIC_CONFIG + hass, + State( + "cover.bla", STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN} + ), + BASIC_CONFIG, ) - assert trt.sync_attributes() == {} + assert trt.sync_attributes() == {"discreteOnlyOpenClose": True} with pytest.raises(helpers.SmartHomeError): trt.query_attributes() @@ -1920,25 +1928,81 @@ async def test_openclose_cover_assumed_state(hass): assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 40} +async def test_openclose_cover_query_only(hass): + """Test OpenClose trait support for cover domain.""" + assert helpers.get_google_type(cover.DOMAIN, None) is not None + assert trait.OpenCloseTrait.supported(cover.DOMAIN, 0, None) + + state = State( + "cover.bla", + cover.STATE_OPEN, + ) + + trt = trait.OpenCloseTrait( + hass, + state, + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "discreteOnlyOpenClose": True, + "queryOnlyOpenClose": True, + } + assert trt.query_attributes() == {"openPercent": 100} + + async def test_openclose_cover_no_position(hass): """Test OpenClose trait support for cover domain.""" assert helpers.get_google_type(cover.DOMAIN, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, cover.SUPPORT_SET_POSITION, None + cover.DOMAIN, cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE, None + ) + + state = State( + "cover.bla", + cover.STATE_OPEN, + { + ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE, + }, ) trt = trait.OpenCloseTrait( - hass, State("cover.bla", cover.STATE_OPEN, {}), BASIC_CONFIG + hass, + state, + BASIC_CONFIG, ) - assert trt.sync_attributes() == {} + assert trt.sync_attributes() == {"discreteOnlyOpenClose": True} assert trt.query_attributes() == {"openPercent": 100} + state.state = cover.STATE_CLOSED + + assert trt.sync_attributes() == {"discreteOnlyOpenClose": True} + assert trt.query_attributes() == {"openPercent": 0} + calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 0}, {}) assert len(calls) == 1 assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 100}, {}) + assert len(calls) == 1 + assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + + with pytest.raises( + SmartHomeError, match=r"Current position not know for relative command" + ): + await trt.execute( + trait.COMMAND_OPENCLOSE_RELATIVE, + BASIC_DATA, + {"openRelativePercent": 100}, + {}, + ) + + with pytest.raises(SmartHomeError, match=r"No support for partial open close"): + await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 50}, {}) + @pytest.mark.parametrize( "device_class", @@ -1996,10 +2060,9 @@ async def test_openclose_cover_secure(hass, device_class): assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 50} # no challenge on close - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER) await trt.execute(trait.COMMAND_OPENCLOSE, PIN_DATA, {"openPercent": 0}, {}) - assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert len(calls) == 2 + assert calls[1].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 0} @pytest.mark.parametrize(