diff --git a/homeassistant/components/alexa_devices/const.py b/homeassistant/components/alexa_devices/const.py index abf8074e1f0..62a7f331223 100644 --- a/homeassistant/components/alexa_devices/const.py +++ b/homeassistant/components/alexa_devices/const.py @@ -29,3 +29,24 @@ COUNTRY_DOMAINS = { CATEGORY_SENSORS = "sensors" CATEGORY_NOTIFICATIONS = "notifications" + +# Map service translation keys to Alexa API +INFO_SKILLS_MAPPING = { + "calendar_today": "Alexa.Calendar.PlayToday", + "calendar_tomorrow": "Alexa.Calendar.PlayTomorrow", + "calendar_next": "Alexa.Calendar.PlayNext", + "date": "Alexa.Date.Play", + "time": "Alexa.Time.Play", + "national_news": "Alexa.News.NationalNews", + "flash_briefing": "Alexa.FlashBriefing.Play", + "traffic": "Alexa.Traffic.Play", + "weather": "Alexa.Weather.Play", + "cleanup": "Alexa.CleanUp.Play", + "good_morning": "Alexa.GoodMorning.Play", + "sing_song": "Alexa.SingASong.Play", + "fun_fact": "Alexa.FunFact.Play", + "tell_joke": "Alexa.Joke.Play", + "tell_story": "Alexa.TellStory.Play", + "im_home": "Alexa.ImHome.Play", + "goodnight": "Alexa.GoodNight.Play", +} diff --git a/homeassistant/components/alexa_devices/icons.json b/homeassistant/components/alexa_devices/icons.json index f9e8de057d0..3de5e5faf6c 100644 --- a/homeassistant/components/alexa_devices/icons.json +++ b/homeassistant/components/alexa_devices/icons.json @@ -1,5 +1,8 @@ { "services": { + "send_info_skill": { + "service": "mdi:information" + }, "send_sound": { "service": "mdi:cast-audio" }, diff --git a/homeassistant/components/alexa_devices/services.py b/homeassistant/components/alexa_devices/services.py index f49a4c1d5a7..5b34a69f47c 100644 --- a/homeassistant/components/alexa_devices/services.py +++ b/homeassistant/components/alexa_devices/services.py @@ -1,5 +1,6 @@ """Support for services.""" +from aioamazondevices.const.metadata import ALEXA_INFO_SKILLS from aioamazondevices.const.sounds import SOUNDS_LIST import voluptuous as vol @@ -9,13 +10,15 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr -from .const import DOMAIN +from .const import DOMAIN, INFO_SKILLS_MAPPING from .coordinator import AmazonConfigEntry ATTR_TEXT_COMMAND = "text_command" ATTR_SOUND = "sound" +ATTR_INFO_SKILL = "info_skill" SERVICE_TEXT_COMMAND = "send_text_command" SERVICE_SOUND_NOTIFICATION = "send_sound" +SERVICE_INFO_SKILL = "send_info_skill" SCHEMA_SOUND_SERVICE = vol.Schema( { @@ -29,6 +32,12 @@ SCHEMA_CUSTOM_COMMAND = vol.Schema( vol.Required(ATTR_DEVICE_ID): cv.string, } ) +SCHEMA_INFO_SKILL = vol.Schema( + { + vol.Required(ATTR_INFO_SKILL): cv.string, + vol.Required(ATTR_DEVICE_ID): cv.string, + } +) @callback @@ -86,6 +95,17 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None: await coordinator.api.call_alexa_text_command( coordinator.data[device.serial_number], value ) + elif attribute == ATTR_INFO_SKILL: + info_skill = INFO_SKILLS_MAPPING.get(value) + if info_skill not in ALEXA_INFO_SKILLS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_info_skill_value", + translation_placeholders={"info_skill": value}, + ) + await coordinator.api.call_alexa_info_skill( + coordinator.data[device.serial_number], value + ) async def async_send_sound_notification(call: ServiceCall) -> None: @@ -98,6 +118,11 @@ async def async_send_text_command(call: ServiceCall) -> None: await _async_execute_action(call, ATTR_TEXT_COMMAND) +async def async_send_info_skill(call: ServiceCall) -> None: + """Send an info skill command to a AmazonDevice.""" + await _async_execute_action(call, ATTR_INFO_SKILL) + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Amazon Devices integration.""" @@ -112,5 +137,10 @@ def async_setup_services(hass: HomeAssistant) -> None: async_send_text_command, SCHEMA_CUSTOM_COMMAND, ), + ( + SERVICE_INFO_SKILL, + async_send_info_skill, + SCHEMA_INFO_SKILL, + ), ): hass.services.async_register(DOMAIN, service_name, method, schema=schema) diff --git a/homeassistant/components/alexa_devices/services.yaml b/homeassistant/components/alexa_devices/services.yaml index 8194e75a8d6..46cf76ccf44 100644 --- a/homeassistant/components/alexa_devices/services.yaml +++ b/homeassistant/components/alexa_devices/services.yaml @@ -67,3 +67,36 @@ send_sound: - squeaky_12 - zap_01 translation_key: sound + +send_info_skill: + fields: + device_id: + required: true + selector: + device: + integration: alexa_devices + info_skill: + required: true + example: date + default: date + selector: + select: + options: + - calendar_today + - calendar_tomorrow + - calendar_next + - date + - time + - national_news + - flash_briefing + - traffic + - weather + - cleanup + - good_morning + - sing_song + - fun_fact + - tell_joke + - tell_story + - im_home + - goodnight + translation_key: info_skill diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 12b9c6948e1..16088364ba9 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -102,11 +102,35 @@ "invalid_device_id": { "message": "Invalid device ID specified: {device_id}" }, + "invalid_info_skill_value": { + "message": "Invalid info skill {info_skill} specified" + }, "invalid_sound_value": { "message": "Invalid sound {sound} specified" } }, "selector": { + "info_skill": { + "options": { + "calendar_next": "Calendar: Next event", + "calendar_today": "Calendar: Today's Calendar", + "calendar_tomorrow": "Calendar: Tomorrow's Calendar", + "cleanup": "Encourage me to clean up", + "date": "Date", + "flash_briefing": "Flash Briefing", + "fun_fact": "Tell me a fun fact", + "good_morning": "Good morning", + "goodnight": "Wish me a good night", + "im_home": "Welcome me home", + "national_news": "National News", + "sing_song": "Sing a song", + "tell_joke": "Tell me a joke", + "tell_story": "Tell me a story", + "time": "Time", + "traffic": "Traffic", + "weather": "Weather" + } + }, "sound": { "options": { "air_horn_03": "Air horn", @@ -154,6 +178,20 @@ } }, "services": { + "send_info_skill": { + "description": "Sends an info skill command to a device", + "fields": { + "device_id": { + "description": "[%key:component::alexa_devices::common::device_id_description%]", + "name": "Device" + }, + "info_skill": { + "description": "The info skill command to send.", + "name": "Alexa info skill command" + } + }, + "name": "Send info skill command" + }, "send_sound": { "description": "Sends a sound to a device", "fields": { diff --git a/tests/components/alexa_devices/snapshots/test_services.ambr b/tests/components/alexa_devices/snapshots/test_services.ambr index c4bcd0fcefc..09cf0988191 100644 --- a/tests/components/alexa_devices/snapshots/test_services.ambr +++ b/tests/components/alexa_devices/snapshots/test_services.ambr @@ -1,4 +1,71 @@ # serializer version: 1 +# name: test_info_skill_service + _Call( + tuple( + dict({ + 'account_name': 'Echo Test', + 'capabilities': list([ + 'AUDIO_PLAYER', + 'MICROPHONE', + ]), + 'device_cluster_members': list([ + 'echo_test_serial_number', + ]), + 'device_family': 'mine', + 'device_owner_customer_id': 'amazon_ower_id', + 'device_type': 'echo', + 'endpoint_id': 'G1234567890123456789012345678A', + 'entity_id': '11111111-2222-3333-4444-555555555555', + 'household_device': False, + 'notifications': dict({ + 'Alarm': dict({ + 'label': 'Morning Alarm', + 'next_occurrence': datetime.datetime(2023, 10, 1, 7, 0, tzinfo=datetime.timezone.utc), + 'status': 'ON', + 'type': 'Alarm', + }), + 'Reminder': dict({ + 'label': 'Take out the trash', + 'next_occurrence': None, + 'status': 'ON', + 'type': 'Reminder', + }), + 'Timer': dict({ + 'label': '', + 'next_occurrence': None, + 'status': 'OFF', + 'type': 'Timer', + }), + }), + 'notifications_supported': True, + 'online': True, + 'sensors': dict({ + 'dnd': dict({ + 'error': False, + 'error_msg': None, + 'error_type': None, + 'name': 'dnd', + 'scale': None, + 'value': False, + }), + 'temperature': dict({ + 'error': False, + 'error_msg': None, + 'error_type': None, + 'name': 'temperature', + 'scale': 'CELSIUS', + 'value': '22.5', + }), + }), + 'serial_number': 'echo_test_serial_number', + 'software_version': 'echo_test_software_version', + }), + 'tell_joke', + ), + dict({ + }), + ) +# --- # name: test_send_sound_service _Call( tuple( diff --git a/tests/components/alexa_devices/test_services.py b/tests/components/alexa_devices/test_services.py index 254daa0749a..db7745ee5b7 100644 --- a/tests/components/alexa_devices/test_services.py +++ b/tests/components/alexa_devices/test_services.py @@ -7,8 +7,10 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.alexa_devices.const import DOMAIN from homeassistant.components.alexa_devices.services import ( + ATTR_INFO_SKILL, ATTR_SOUND, ATTR_TEXT_COMMAND, + SERVICE_INFO_SKILL, SERVICE_SOUND_NOTIFICATION, SERVICE_TEXT_COMMAND, ) @@ -35,6 +37,37 @@ async def test_setup_services( assert (services := hass.services.async_services_for_domain(DOMAIN)) assert SERVICE_TEXT_COMMAND in services assert SERVICE_SOUND_NOTIFICATION in services + assert SERVICE_INFO_SKILL in services + + +async def test_info_skill_service( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test info skill service.""" + + await setup_integration(hass, mock_config_entry) + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + ) + assert device_entry + + await hass.services.async_call( + DOMAIN, + SERVICE_INFO_SKILL, + { + ATTR_INFO_SKILL: "tell_joke", + ATTR_DEVICE_ID: device_entry.id, + }, + blocking=True, + ) + + assert mock_amazon_devices_client.call_alexa_info_skill.call_count == 1 + assert mock_amazon_devices_client.call_alexa_info_skill.call_args == snapshot async def test_send_sound_service( @@ -153,6 +186,62 @@ async def test_invalid_parameters( assert exc_info.value.translation_placeholders == translation_placeholders +@pytest.mark.parametrize( + ("info_skill", "device_id", "translation_key", "translation_placeholders"), + [ + ( + "tell_joke", + "fake_device_id", + "invalid_device_id", + {"device_id": "fake_device_id"}, + ), + ( + "wrong_info_skill_name", + TEST_DEVICE_1_ID, + "invalid_info_skill_value", + { + "info_skill": "wrong_info_skill_name", + }, + ), + ], +) +async def test_invalid_info_skillparameters( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + info_skill: str, + device_id: str, + translation_key: str, + translation_placeholders: dict[str, str], +) -> None: + """Test invalid info skill service parameters.""" + + device_entry = dr.DeviceEntry( + id=TEST_DEVICE_1_ID, identifiers={(DOMAIN, TEST_DEVICE_1_SN)} + ) + mock_device_registry( + hass, + {device_entry.id: device_entry}, + ) + await setup_integration(hass, mock_config_entry) + + # Call Service + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + DOMAIN, + SERVICE_INFO_SKILL, + { + ATTR_INFO_SKILL: info_skill, + ATTR_DEVICE_ID: device_id, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_placeholders == translation_placeholders + + async def test_config_entry_not_loaded( hass: HomeAssistant, device_registry: dr.DeviceRegistry,