From e44f00cf7cf9b34553f3977d4cbf553094bc83a8 Mon Sep 17 00:00:00 2001 From: CrazyMan2000 <45339639+CrazyMan2000@users.noreply.github.com> Date: Tue, 9 Jul 2024 18:03:47 +0200 Subject: [PATCH] Add alexa remote support (#120878) * Updated the AlexaModeController to support the remote domain. Also added an alexa entitiy adapter for the remote domain. * Fixed copy paste mistake. * Fixed power state for remove domain. * Updated the CapabilityResource to support labels with the corresponding locale. This local is read from the users config. * Add the alexa display category 'REMOTE' and use it for the remote capability. * Revert "Updated the CapabilityResource to support labels with the corresponding locale. This local is read from the users config." This reverts commit fbdf37904a3e613f4d3107a1b176fdfe6c9429bf. * Fix error when the remote does not have an activtiy list. * Add tests for the state report of a remote entity. * Add a test for alexas set mode directive for a remote entitiy. * Add a test for alexas TurnOn and TurnOff directives for a remote entity. * Apply suggestions from code review Fix copy paste mistakes. Co-authored-by: Jan Bouwhuis * Improve attribute name as suggested. Co-authored-by: Jan Bouwhuis * Add test case with zero and one activity. * Add a comment why we use the mode controller instead of the input controller. * Add test to check of the discovery returns all required interfaces for a remote entitiy. * Tweak comment * Add line breaks to fix max allowed chars per line. --------- Co-authored-by: Jan Bouwhuis --- .../components/alexa/capabilities.py | 27 +++ homeassistant/components/alexa/const.py | 2 +- homeassistant/components/alexa/entities.py | 23 +++ homeassistant/components/alexa/handlers.py | 16 ++ tests/components/alexa/test_capabilities.py | 187 ++++++++++++++++++ 5 files changed, 254 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 44dfae33e18..03ba353bb5b 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -18,6 +18,7 @@ from homeassistant.components import ( light, media_player, number, + remote, timer, vacuum, valve, @@ -438,6 +439,8 @@ class AlexaPowerController(AlexaCapability): is_on = self.entity.state == fan.STATE_ON elif self.entity.domain == humidifier.DOMAIN: is_on = self.entity.state == humidifier.STATE_ON + elif self.entity.domain == remote.DOMAIN: + is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN) elif self.entity.domain == vacuum.DOMAIN: is_on = self.entity.state == vacuum.STATE_CLEANING elif self.entity.domain == timer.DOMAIN: @@ -1435,6 +1438,12 @@ class AlexaModeController(AlexaCapability): if mode in modes: return f"{humidifier.ATTR_MODE}.{mode}" + # Remote Activity + if self.instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}": + activity = self.entity.attributes.get(remote.ATTR_CURRENT_ACTIVITY, None) + if activity in self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST, []): + return f"{remote.ATTR_ACTIVITY}.{activity}" + # Water heater operation mode if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": operation_mode = self.entity.attributes.get( @@ -1549,6 +1558,24 @@ class AlexaModeController(AlexaCapability): ) return self._resource.serialize_capability_resources() + # Remote Resource + if self.instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}": + # Use the mode controller for a remote because the input controller + # only allows a preset of names as an input. + self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) + activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or [] + for activity in activities: + self._resource.add_mode( + f"{remote.ATTR_ACTIVITY}.{activity}", [activity] + ) + # Remotes with a single activity completely break Alexa discovery, add a + # fake activity to the mode controller (see issue #53832). + if len(activities) == 1: + self._resource.add_mode( + f"{remote.ATTR_ACTIVITY}.{PRESET_MODE_NA}", [PRESET_MODE_NA] + ) + return self._resource.serialize_capability_resources() + # Cover Position Resources if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": self._resource = AlexaModeResource( diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index 2c615b71166..4862e4d8a8c 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -88,7 +88,7 @@ API_THERMOSTAT_MODES_CUSTOM = { API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} # AlexaModeController does not like a single mode for the fan preset or humidifier mode, -# we add PRESET_MODE_NA if a fan / humidifier has only one preset_mode +# we add PRESET_MODE_NA if a fan / humidifier / remote has only one preset_mode PRESET_MODE_NA = "-" STORAGE_ACCESS_TOKEN = "access_token" diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index d3e9f2a8e7d..8bba4ed2468 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -27,6 +27,7 @@ from homeassistant.components import ( lock, media_player, number, + remote, scene, script, sensor, @@ -196,6 +197,10 @@ class DisplayCategory: # Indicates a device that prints. PRINTER = "PRINTER" + # Indicates a decive that support stateless events, + # such as remote switches and smart buttons. + REMOTE = "REMOTE" + # Indicates a network router. ROUTER = "ROUTER" @@ -645,6 +650,24 @@ class FanCapabilities(AlexaEntity): yield Alexa(self.entity) +@ENTITY_ADAPTERS.register(remote.DOMAIN) +class RemoteCapabilities(AlexaEntity): + """Class to represent Remote capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.REMOTE] + + def interfaces(self) -> Generator[AlexaCapability]: + """Yield the supported interfaces.""" + yield AlexaPowerController(self.entity) + yield AlexaModeController( + self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}" + ) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + @ENTITY_ADAPTERS.register(humidifier.DOMAIN) class HumidifierCapabilities(AlexaEntity): """Class to represent Humidifier capabilities.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 47e09db1166..6df4beccdc8 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -21,6 +21,7 @@ from homeassistant.components import ( light, media_player, number, + remote, timer, vacuum, valve, @@ -185,6 +186,8 @@ async def async_api_turn_on( service = fan.SERVICE_TURN_ON elif domain == humidifier.DOMAIN: service = humidifier.SERVICE_TURN_ON + elif domain == remote.DOMAIN: + service = remote.SERVICE_TURN_ON elif domain == vacuum.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if ( @@ -234,6 +237,8 @@ async def async_api_turn_off( service = climate.SERVICE_TURN_OFF elif domain == fan.DOMAIN: service = fan.SERVICE_TURN_OFF + elif domain == remote.DOMAIN: + service = remote.SERVICE_TURN_OFF elif domain == humidifier.DOMAIN: service = humidifier.SERVICE_TURN_OFF elif domain == vacuum.DOMAIN: @@ -1200,6 +1205,17 @@ async def async_api_set_mode( msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'" raise AlexaInvalidValueError(msg) + # Remote Activity + if instance == f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}": + activity = mode.split(".")[1] + activities: list[str] | None = entity.attributes.get(remote.ATTR_ACTIVITY_LIST) + if activity != PRESET_MODE_NA and activities and activity in activities: + service = remote.SERVICE_TURN_ON + data[remote.ATTR_ACTIVITY] = activity + else: + msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'" + raise AlexaInvalidValueError(msg) + # Water heater operation mode elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": operation_mode = mode.split(".")[1] diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 15a4bd6d9a1..d66f487d76d 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -48,6 +48,41 @@ from .test_common import ( from tests.common import async_mock_service +@pytest.mark.parametrize( + ( + "curr_activity", + "activity_list", + ), + [ + ("TV", ["TV", "MUSIC", "DVD"]), + ("TV", ["TV"]), + ], +) +async def test_discovery_remote( + hass: HomeAssistant, curr_activity: str, activity_list: list[str] +) -> None: + """Test discory for a remote entity.""" + request = get_new_request("Alexa.Discovery", "Discover") + # setup test device + hass.states.async_set( + "remote.test", + "off", + { + "current_activity": curr_activity, + "activity_list": activity_list, + }, + ) + msg = await smart_home.async_handle_message(hass, get_default_config(hass), request) + assert "event" in msg + msg = msg["event"] + assert len(msg["payload"]["endpoints"]) == 1 + endpoint = msg["payload"]["endpoints"][0] + assert endpoint["endpointId"] == "remote#test" + interfaces = {capability["interface"] for capability in endpoint["capabilities"]} + assert "Alexa.PowerController" in interfaces + assert "Alexa.ModeController" in interfaces + + @pytest.mark.parametrize("adjust", ["-5", "5", "-80"]) async def test_api_adjust_brightness(hass: HomeAssistant, adjust: str) -> None: """Test api adjust brightness process.""" @@ -243,6 +278,102 @@ async def test_api_select_input( assert call.data["source"] == source_list[idx] +@pytest.mark.parametrize( + ( + "target_activity", + "activity_list", + "current_activity_index", + "target_activity_index", + ), + [ + ("TV", ["TV", "MUSIC", "DVD"], 1, 0), + ("MUSIC", ["TV", "MUSIC", "DVD", 1000], 0, 1), + ("DVD", ["TV", "MUSIC", "DVD", None], 0, 2), + ("BAD DEVICE", ["TV", "MUSIC", "DVD"], 0, None), + ("TV", ["TV"], 0, 0), + ("BAD DEVICE", [], None, None), + ], +) +async def test_api_select_activity( + hass: HomeAssistant, + target_activity: str, + activity_list: list[str], + current_activity_index: int | None, + target_activity_index: int | None, +) -> None: + """Test api set activity process.""" + curr_activty = ( + activity_list[current_activity_index] if current_activity_index else "None" + ) + hass.states.async_set( + "remote.test", + "off", + { + "current_activity": curr_activty, + "activity_list": activity_list, + }, + ) + # test where no source matches + if target_activity_index is None: + await assert_request_fails( + "Alexa.ModeController", + "SetMode", + "remote#test", + "remote.turn_on", + hass, + payload={"mode": f"activity.{target_activity}"}, + instance="remote.activity", + ) + return + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "remote#test", + "remote.turn_on", + hass, + payload={"mode": f"activity.{target_activity}"}, + instance="remote.activity", + ) + assert call.data["activity"] == activity_list[target_activity_index] + + +@pytest.mark.parametrize( + ( + "curr_state", + "target_name", + "target_service", + ), + [ + ("on", "TurnOff", "turn_off"), + ("off", "TurnOn", "turn_on"), + ], +) +async def test_api_remote_set_power_state( + hass: HomeAssistant, + curr_state: str, + target_name: str, + target_service: str, +) -> None: + """Test api remote set power state process.""" + hass.states.async_set( + "remote.test", + curr_state, + { + "current_activity": ["TV", "MUSIC", "DVD"], + "activity_list": "TV", + }, + ) + + _, msg = await assert_request_calls_service( + "Alexa.PowerController", + target_name, + "remote#test", + f"remote.{target_service}", + hass, + ) + + async def test_report_lock_state(hass: HomeAssistant) -> None: """Test LockController implements lockState property.""" hass.states.async_set("lock.locked", STATE_LOCKED, {}) @@ -619,6 +750,62 @@ async def test_report_fan_direction(hass: HomeAssistant) -> None: properties.assert_equal("Alexa.ModeController", "mode", "direction.forward") +async def test_report_remote_power(hass: HomeAssistant) -> None: + """Test ModeController reports remote power state correctly.""" + hass.states.async_set( + "remote.off", + "off", + {"current_activity": "TV", "activity_list": ["TV", "MUSIC", "DVD"]}, + ) + hass.states.async_set( + "remote.on", + "on", + {"current_activity": "TV", "activity_list": ["TV", "MUSIC", "DVD"]}, + ) + + properties = await reported_properties(hass, "remote#off") + properties.assert_equal("Alexa.PowerController", "powerState", "OFF") + + properties = await reported_properties(hass, "remote#on") + properties.assert_equal("Alexa.PowerController", "powerState", "ON") + + +async def test_report_remote_activity(hass: HomeAssistant) -> None: + """Test ModeController reports remote activity correctly.""" + hass.states.async_set( + "remote.unknown", + "on", + {"current_activity": "UNKNOWN"}, + ) + hass.states.async_set( + "remote.tv", + "on", + {"current_activity": "TV", "activity_list": ["TV", "MUSIC", "DVD"]}, + ) + hass.states.async_set( + "remote.music", + "on", + {"current_activity": "MUSIC", "activity_list": ["TV", "MUSIC", "DVD"]}, + ) + hass.states.async_set( + "remote.dvd", + "on", + {"current_activity": "DVD", "activity_list": ["TV", "MUSIC", "DVD"]}, + ) + + properties = await reported_properties(hass, "remote#unknown") + properties.assert_not_has_property("Alexa.ModeController", "mode") + + properties = await reported_properties(hass, "remote#tv") + properties.assert_equal("Alexa.ModeController", "mode", "activity.TV") + + properties = await reported_properties(hass, "remote#music") + properties.assert_equal("Alexa.ModeController", "mode", "activity.MUSIC") + + properties = await reported_properties(hass, "remote#dvd") + properties.assert_equal("Alexa.ModeController", "mode", "activity.DVD") + + async def test_report_cover_range_value(hass: HomeAssistant) -> None: """Test RangeController reports cover position correctly.""" hass.states.async_set(