From 1dc496a2dd63fe6c96ce22d3d0952ceddec909dc Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 9 Sep 2024 07:25:25 -0500 Subject: [PATCH] Add announce support to ESPHome Assist Satellite platform (#125157) Rebuild --- .../components/esphome/assist_satellite.py | 22 +++ .../esphome/test_assist_satellite.py | 147 ++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index f84940eadc4..9d48e96b52e 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -74,6 +74,8 @@ _TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventTy ) ) +_ANNOUNCEMENT_TIMEOUT_SEC = 5 * 60 # 5 minutes + async def async_setup_entry( hass: HomeAssistant, @@ -183,6 +185,12 @@ class EsphomeAssistSatellite( ) ) + if feature_flags & VoiceAssistantFeature.ANNOUNCE: + # Device supports announcements + self._attr_supported_features |= ( + assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() @@ -251,6 +259,20 @@ class EsphomeAssistSatellite( self.cli.send_voice_assistant_event(event_type, data_to_send) + async def async_announce(self, message: str, media_id: str) -> None: + """Announce media on the satellite. + + Should block until the announcement is done playing. + """ + _LOGGER.debug( + "Waiting for announcement to finished (message=%s, media_id=%s)", + message, + media_id, + ) + await self.cli.send_voice_assistant_announcement_await_response( + media_id, _ANNOUNCEMENT_TIMEOUT_SEC, message + ) + async def handle_pipeline_start( self, conversation_id: str, diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 1c7f7320a85..e245cfcf3bf 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -27,6 +27,7 @@ from homeassistant.components import assist_satellite, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite.entity import ( AssistSatelliteEntity, + AssistSatelliteEntityFeature, AssistSatelliteState, ) from homeassistant.components.esphome import DOMAIN @@ -34,6 +35,7 @@ from homeassistant.components.esphome.assist_satellite import ( EsphomeAssistSatellite, VoiceAssistantUDPServer, ) +from homeassistant.components.media_source import PlayMedia from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, intent as intent_helper @@ -891,3 +893,148 @@ async def test_tts_format_from_media_player( tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 1, tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, } + + +async def test_announce_supported_features( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that the announce supported feature is set by flags.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + assert not (satellite.supported_features & AssistSatelliteEntityFeature.ANNOUNCE) + + +async def test_announce_message( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test announcement with message.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, timeout: float, text: str + ): + assert media_id == "https://www.home-assistant.io/resolved.mp3" + assert text == "test-text" + + done.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "announce", + {"entity_id": satellite.entity_id, "message": "test-text"}, + blocking=True, + ) + await done.wait() + + +async def test_announce_media_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test announcement with media id.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, timeout: float, text: str + ): + assert media_id == "https://www.home-assistant.io/resolved.mp3" + + done.set() + + with ( + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "announce", + { + "entity_id": satellite.entity_id, + "media_id": "https://www.home-assistant.io/resolved.mp3", + }, + blocking=True, + ) + await done.wait()