From f536bc1d0c3ab93539d7842eb6ae3323a182e061 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 22 Dec 2023 12:08:06 +0100 Subject: [PATCH] Add valve support to Amazon Alexa (#106053) Add valve platform to Amazon Alexa --- .../components/alexa/capabilities.py | 110 ++++ homeassistant/components/alexa/entities.py | 26 + homeassistant/components/alexa/handlers.py | 59 +- tests/components/alexa/test_capabilities.py | 138 +++++ tests/components/alexa/test_smart_home.py | 553 +++++++++++++++++- 5 files changed, 876 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 955502c8149f..502912ee8de8 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -19,6 +19,7 @@ from homeassistant.components import ( number, timer, vacuum, + valve, water_heater, ) from homeassistant.components.alarm_control_panel import ( @@ -1444,6 +1445,19 @@ class AlexaModeController(AlexaCapability): ): return f"{cover.ATTR_POSITION}.{mode}" + # Valve position state + if self.instance == f"{valve.DOMAIN}.state": + # Return state instead of position when using ModeController. + state = self.entity.state + if state in ( + valve.STATE_OPEN, + valve.STATE_OPENING, + valve.STATE_CLOSED, + valve.STATE_CLOSING, + STATE_UNKNOWN, + ): + return f"state.{state}" + return None def configuration(self) -> dict[str, Any] | None: @@ -1540,6 +1554,32 @@ class AlexaModeController(AlexaCapability): ) return self._resource.serialize_capability_resources() + # Valve position resources + if self.instance == f"{valve.DOMAIN}.state": + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + self._resource = AlexaModeResource( + ["Preset", AlexaGlobalCatalog.SETTING_PRESET], False + ) + modes = 0 + if supported_features & valve.ValveEntityFeature.OPEN: + self._resource.add_mode( + f"state.{valve.STATE_OPEN}", + ["Open", AlexaGlobalCatalog.SETTING_PRESET], + ) + modes += 1 + if supported_features & valve.ValveEntityFeature.CLOSE: + self._resource.add_mode( + f"state.{valve.STATE_CLOSED}", + ["Closed", AlexaGlobalCatalog.SETTING_PRESET], + ) + modes += 1 + + # Alexa requiers at least 2 modes + if modes == 1: + self._resource.add_mode(f"state.{PRESET_MODE_NA}", [PRESET_MODE_NA]) + + return self._resource.serialize_capability_resources() + return {} def semantics(self) -> dict[str, Any] | None: @@ -1578,6 +1618,34 @@ class AlexaModeController(AlexaCapability): return self._semantics.serialize_semantics() + # Valve Position + if self.instance == f"{valve.DOMAIN}.state": + close_labels = [AlexaSemantics.ACTION_CLOSE] + open_labels = [AlexaSemantics.ACTION_OPEN] + self._semantics = AlexaSemantics() + + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_CLOSED], + f"state.{valve.STATE_CLOSED}", + ) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_OPEN], + f"state.{valve.STATE_OPEN}", + ) + + self._semantics.add_action_to_directive( + close_labels, + "SetMode", + {"mode": f"state.{valve.STATE_CLOSED}"}, + ) + self._semantics.add_action_to_directive( + open_labels, + "SetMode", + {"mode": f"state.{valve.STATE_OPEN}"}, + ) + + return self._semantics.serialize_semantics() + return None @@ -1691,6 +1759,10 @@ class AlexaRangeController(AlexaCapability): ) return speed_index + # Valve Position + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + return self.entity.attributes.get(valve.ATTR_CURRENT_POSITION) + return None def configuration(self) -> dict[str, Any] | None: @@ -1814,6 +1886,17 @@ class AlexaRangeController(AlexaCapability): return self._resource.serialize_capability_resources() + # Valve Position Resources + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + self._resource = AlexaPresetResource( + ["Opening", AlexaGlobalCatalog.SETTING_OPENING], + min_value=0, + max_value=100, + precision=1, + unit=AlexaGlobalCatalog.UNIT_PERCENT, + ) + return self._resource.serialize_capability_resources() + return {} def semantics(self) -> dict[str, Any] | None: @@ -1890,6 +1973,25 @@ class AlexaRangeController(AlexaCapability): ) return self._semantics.serialize_semantics() + # Valve Position + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + close_labels = [AlexaSemantics.ACTION_CLOSE] + open_labels = [AlexaSemantics.ACTION_OPEN] + self._semantics = AlexaSemantics() + + self._semantics.add_states_to_value([AlexaSemantics.STATES_CLOSED], value=0) + self._semantics.add_states_to_range( + [AlexaSemantics.STATES_OPEN], min_value=1, max_value=100 + ) + + self._semantics.add_action_to_directive( + close_labels, "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + open_labels, "SetRangeValue", {"rangeValue": 100} + ) + return self._semantics.serialize_semantics() + return None @@ -1963,6 +2065,10 @@ class AlexaToggleController(AlexaCapability): is_on = bool(self.entity.attributes.get(fan.ATTR_OSCILLATING)) return "ON" if is_on else "OFF" + # Stop Valve + if self.instance == f"{valve.DOMAIN}.stop": + return "OFF" + return None def capability_resources(self) -> dict[str, list[dict[str, Any]]]: @@ -1975,6 +2081,10 @@ class AlexaToggleController(AlexaCapability): ) return self._resource.serialize_capability_resources() + if self.instance == f"{valve.DOMAIN}.stop": + self._resource = AlexaCapabilityResource(["Stop"]) + return self._resource.serialize_capability_resources() + return {} diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 2f89058514b9..d0e265b8454d 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -32,6 +32,7 @@ from homeassistant.components import ( switch, timer, vacuum, + valve, water_heater, ) from homeassistant.const import ( @@ -976,6 +977,31 @@ class VacuumCapabilities(AlexaEntity): yield Alexa(self.entity) +@ENTITY_ADAPTERS.register(valve.DOMAIN) +class ValveCapabilities(AlexaEntity): + """Class to represent Valve capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self) -> Generator[AlexaCapability, None, None]: + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & valve.ValveEntityFeature.SET_POSITION: + yield AlexaRangeController( + self.entity, instance=f"{valve.DOMAIN}.{valve.ATTR_POSITION}" + ) + elif supported & ( + valve.ValveEntityFeature.CLOSE | valve.ValveEntityFeature.OPEN + ): + yield AlexaModeController(self.entity, instance=f"{valve.DOMAIN}.state") + if supported & valve.ValveEntityFeature.STOP: + yield AlexaToggleController(self.entity, instance=f"{valve.DOMAIN}.stop") + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + @ENTITY_ADAPTERS.register(camera.DOMAIN) class CameraCapabilities(AlexaEntity): """Class to represent Camera capabilities.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 8e81cf1a2c62..5613da52db50 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -22,6 +22,7 @@ from homeassistant.components import ( number, timer, vacuum, + valve, water_heater, ) from homeassistant.const import ( @@ -1216,6 +1217,15 @@ async def async_api_set_mode( elif position == "custom": service = cover.SERVICE_STOP_COVER + # Valve position state + elif instance == f"{valve.DOMAIN}.state": + position = mode.split(".")[1] + + if position == valve.STATE_CLOSED: + service = valve.SERVICE_CLOSE_VALVE + elif position == valve.STATE_OPEN: + service = valve.SERVICE_OPEN_VALVE + if not service: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) @@ -1266,15 +1276,22 @@ async def async_api_toggle_on( instance = directive.instance domain = entity.domain - # Fan Oscillating - if instance != f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": - raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + data: dict[str, Any] - service = fan.SERVICE_OSCILLATE - data: dict[str, Any] = { - ATTR_ENTITY_ID: entity.entity_id, - fan.ATTR_OSCILLATING: True, - } + # Fan Oscillating + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data = { + ATTR_ENTITY_ID: entity.entity_id, + fan.ATTR_OSCILLATING: True, + } + elif instance == f"{valve.DOMAIN}.stop": + service = valve.SERVICE_STOP_VALVE + data = { + ATTR_ENTITY_ID: entity.entity_id, + } + else: + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) await hass.services.async_call( domain, service, data, blocking=False, context=context @@ -1417,6 +1434,17 @@ async def async_api_set_range( data[vacuum.ATTR_FAN_SPEED] = speed + # Valve Position + elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + range_value = int(range_value) + if supported & valve.ValveEntityFeature.CLOSE and range_value == 0: + service = valve.SERVICE_CLOSE_VALVE + elif supported & valve.ValveEntityFeature.OPEN and range_value == 100: + service = valve.SERVICE_OPEN_VALVE + else: + service = valve.SERVICE_SET_VALVE_POSITION + data[valve.ATTR_POSITION] = range_value + else: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) @@ -1562,6 +1590,21 @@ async def async_api_adjust_range( ) data[vacuum.ATTR_FAN_SPEED] = response_value = speed + # Valve Position + elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) + service = valve.SERVICE_SET_VALVE_POSITION + if not (current := entity.attributes.get(valve.ATTR_POSITION)): + msg = f"Unable to determine {entity.entity_id} current position" + raise AlexaInvalidValueError(msg) + position = response_value = min(100, max(0, range_delta + current)) + if position == 100: + service = valve.SERVICE_OPEN_VALVE + elif position == 0: + service = valve.SERVICE_CLOSE_VALVE + else: + data[valve.ATTR_POSITION] = position + else: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 7c39e34ac38d..b83bdb794a8a 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -8,6 +8,7 @@ from homeassistant.components.alexa import smart_home from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.valve import ValveEntityFeature from homeassistant.components.water_heater import ( ATTR_OPERATION_LIST, ATTR_OPERATION_MODE, @@ -653,6 +654,143 @@ async def test_report_cover_range_value(hass: HomeAssistant) -> None: properties.assert_equal("Alexa.RangeController", "rangeValue", 0) +async def test_report_valve_range_value(hass: HomeAssistant) -> None: + """Test RangeController reports valve position correctly.""" + all_valve_features = ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION + ) + hass.states.async_set( + "valve.fully_open", + "open", + { + "friendly_name": "Fully open valve", + "current_position": 100, + "supported_features": all_valve_features, + }, + ) + hass.states.async_set( + "valve.half_open", + "open", + { + "friendly_name": "Half open valve", + "current_position": 50, + "supported_features": all_valve_features, + }, + ) + hass.states.async_set( + "valve.closed", + "closed", + { + "friendly_name": "Closed valve", + "current_position": 0, + "supported_features": all_valve_features, + }, + ) + + properties = await reported_properties(hass, "valve.fully_open") + properties.assert_equal("Alexa.RangeController", "rangeValue", 100) + + properties = await reported_properties(hass, "valve.half_open") + properties.assert_equal("Alexa.RangeController", "rangeValue", 50) + + properties = await reported_properties(hass, "valve.closed") + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) + + +@pytest.mark.parametrize( + ( + "supported_features", + "has_mode_controller", + "has_range_controller", + "has_toggle_controller", + ), + [ + (ValveEntityFeature(0), False, False, False), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + True, + False, + True, + ), + ( + ValveEntityFeature.OPEN, + True, + False, + False, + ), + ( + ValveEntityFeature.CLOSE, + True, + False, + False, + ), + ( + ValveEntityFeature.STOP, + False, + False, + True, + ), + ( + ValveEntityFeature.SET_POSITION, + False, + True, + False, + ), + ( + ValveEntityFeature.STOP | ValveEntityFeature.SET_POSITION, + False, + True, + True, + ), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.SET_POSITION, + False, + True, + False, + ), + ], +) +async def test_report_valve_controllers( + hass: HomeAssistant, + supported_features: ValveEntityFeature, + has_mode_controller: bool, + has_range_controller: bool, + has_toggle_controller: bool, +) -> None: + """Test valve controllers are reported correctly.""" + hass.states.async_set( + "valve.custom", + "opening", + { + "friendly_name": "Custom valve", + "current_position": 0, + "supported_features": supported_features, + }, + ) + + properties = await reported_properties(hass, "valve.custom") + + if has_mode_controller: + properties.assert_equal("Alexa.ModeController", "mode", "state.opening") + else: + properties.assert_not_has_property("Alexa.ModeController", "mode") + if has_range_controller: + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) + else: + properties.assert_not_has_property("Alexa.RangeController", "rangeValue") + if has_toggle_controller: + properties.assert_equal("Alexa.ToggleController", "toggleState", "OFF") + else: + properties.assert_not_has_property("Alexa.ToggleController", "toggleState") + + async def test_report_climate_state(hass: HomeAssistant) -> None: """Test ThermostatController reports state correctly.""" for auto_modes in (HVACMode.AUTO, HVACMode.HEAT_COOL): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index d025b1586f5b..ff8fef43a66f 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -9,8 +9,14 @@ import homeassistant.components.camera as camera from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.valve import SERVICE_STOP_VALVE, ValveEntityFeature from homeassistant.config import async_process_ha_core_config -from homeassistant.const import STATE_UNKNOWN, UnitOfTemperature +from homeassistant.const import ( + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + STATE_UNKNOWN, + UnitOfTemperature, +) from homeassistant.core import Context, Event, HomeAssistant from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component @@ -156,7 +162,7 @@ def assert_endpoint_capabilities(endpoint, *interfaces): capabilities = endpoint["capabilities"] supported = {feature["interface"] for feature in capabilities} - assert supported == set(interfaces) + assert supported == {interface for interface in interfaces if interface is not None} return capabilities @@ -2069,6 +2075,216 @@ async def test_cover_position( assert properties["value"] == position +@pytest.mark.parametrize( + ( + "position", + "position_attr_in_service_call", + "supported_features", + "service_call", + "has_toggle_controller", + ), + [ + ( + 30, + 30, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + "valve.set_valve_position", + True, + ), + ( + 0, + None, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.close_valve", + False, + ), + ( + 99, + 99, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.set_valve_position", + False, + ), + ( + 100, + None, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.open_valve", + False, + ), + ( + 0, + 0, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 60, + 60, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 60, + 60, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP, + "valve.set_valve_position", + True, + ), + ( + 100, + 100, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 0, + 0, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.OPEN, + "valve.set_valve_position", + False, + ), + ( + 100, + 100, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.CLOSE, + "valve.set_valve_position", + False, + ), + ], + ids=[ + "position_30_open_close_stop", + "position_0_open_close", + "position_99_open_close", + "position_100_open_close", + "position_0_no_open_close", + "position_60_no_open_close", + "position_60_stop_no_open_close", + "position_100_no_open_close", + "position_0_no_close", + "position_100_no_open", + ], +) +async def test_valve_position( + hass: HomeAssistant, + position: int, + position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, + has_toggle_controller: bool, +) -> None: + """Test cover discovery and position using rangeController.""" + device = ( + "valve.test_range", + "open", + { + "friendly_name": "Test valve range", + "device_class": "water", + "supported_features": supported_features, + "position": position, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_range" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController" if has_toggle_controller else None, + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "valve.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Opening", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "valve#test_range", + service_call, + hass, + payload={"rangeValue": position}, + instance="valve.position", + ) + assert call.data.get("position") == position_attr_in_service_call + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == position + + async def test_cover_position_range( hass: HomeAssistant, ) -> None: @@ -2186,6 +2402,208 @@ async def test_cover_position_range( ) +async def test_valve_position_range( + hass: HomeAssistant, +) -> None: + """Test valve discovery and position range using rangeController. + + Also tests an invalid valve position being handled correctly. + """ + + device = ( + "valve.test_range", + "open", + { + "friendly_name": "Test valve range", + "device_class": "water", + "supported_features": 15, + "position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_range" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "valve.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Opening", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.open_valve", + hass, + payload={"rangeValueDelta": 101, "rangeValueDeltaDefault": False}, + instance="valve.position", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == 100 + assert call.service == SERVICE_OPEN_VALVE + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.close_valve", + hass, + payload={"rangeValueDelta": -99, "rangeValueDeltaDefault": False}, + instance="valve.position", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == 0 + assert call.service == SERVICE_CLOSE_VALVE + + await assert_range_changes( + hass, + [(25, -5, False), (35, 5, False), (50, 1, True), (10, -1, True)], + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.set_valve_position", + "position", + instance="valve.position", + ) + + +@pytest.mark.parametrize( + ("supported_features", "state_controller"), + [ + ( + ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP, + "Alexa.RangeController", + ), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + "Alexa.ModeController", + ), + ], +) +async def test_stop_valve( + hass: HomeAssistant, supported_features: ValveEntityFeature, state_controller: str +) -> None: + """Test stop valve ToggleController.""" + device = ( + "valve.test", + "opening", + { + "friendly_name": "Test valve", + "supported_features": supported_features, + "current_position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve" + capabilities = assert_endpoint_capabilities( + appliance, + state_controller, + "Alexa.ToggleController", + "Alexa.EndpointHealth", + "Alexa", + ) + + toggle_capability = get_capability(capabilities, "Alexa.ToggleController") + assert toggle_capability is not None + assert toggle_capability["instance"] == "valve.stop" + + properties = toggle_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "toggleState"} in properties["supported"] + + capability_resources = toggle_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Stop", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + call, _ = await assert_request_calls_service( + "Alexa.ToggleController", + "TurnOn", + "valve#test", + "valve.stop_valve", + hass, + payload={}, + instance="valve.stop", + ) + assert call.data["entity_id"] == "valve.test" + assert call.service == SERVICE_STOP_VALVE + + async def assert_percentage_changes( hass, adjustments, namespace, name, endpoint, parameter, service, changed_parameter ): @@ -3667,6 +4085,137 @@ async def test_cover_position_mode(hass: HomeAssistant) -> None: assert properties["value"] == "position.custom" +async def test_valve_position_mode(hass: HomeAssistant) -> None: + """Test valve discovery and position using modeController.""" + device = ( + "valve.test_mode", + "open", + { + "friendly_name": "Test valve mode", + "device_class": "water", + "supported_features": ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_mode" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve mode" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.ModeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController", + "Alexa", + ) + + mode_capability = get_capability(capabilities, "Alexa.ModeController") + assert mode_capability is not None + assert mode_capability["instance"] == "valve.state" + + properties = mode_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "mode"} in properties["supported"] + + capability_resources = mode_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Preset", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Preset"}, + } in capability_resources["friendlyNames"] + + configuration = mode_capability["configuration"] + assert configuration is not None + assert configuration["ordered"] is False + + supported_modes = configuration["supportedModes"] + assert supported_modes is not None + assert { + "value": "state.open", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "Open", "locale": "en-US"}}, + {"@type": "asset", "value": {"assetId": "Alexa.Setting.Preset"}}, + ] + }, + } in supported_modes + assert { + "value": "state.closed", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "Closed", "locale": "en-US"}}, + {"@type": "asset", "value": {"assetId": "Alexa.Setting.Preset"}}, + ] + }, + } in supported_modes + + # Assert for Position Semantics + position_semantics = mode_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetMode", "payload": {"mode": "state.closed"}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetMode", "payload": {"mode": "state.open"}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": "state.closed", + } in position_state_mappings + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Open"], + "value": "state.open", + } in position_state_mappings + + _, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "valve#test_mode", + "valve.close_valve", + hass, + payload={"mode": "state.closed"}, + instance="valve.state", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "state.closed" + + _, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "valve#test_mode", + "valve.open_valve", + hass, + payload={"mode": "state.open"}, + instance="valve.state", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "state.open" + + async def test_image_processing(hass: HomeAssistant) -> None: """Test image_processing discovery as event detection.""" device = (