diff --git a/homeassistant/components/assist_satellite/intent.py b/homeassistant/components/assist_satellite/intent.py new file mode 100644 index 00000000000..75396cf138f --- /dev/null +++ b/homeassistant/components/assist_satellite/intent.py @@ -0,0 +1,69 @@ +"""Assist Satellite intents.""" + +import voluptuous as vol + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, intent + +from .const import DOMAIN, AssistSatelliteEntityFeature + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the intents.""" + intent.async_register(hass, BroadcastIntentHandler()) + + +class BroadcastIntentHandler(intent.IntentHandler): + """Broadcast a message.""" + + intent_type = intent.INTENT_BROADCAST + description = "Broadcast a message through the home" + + @property + def slot_schema(self) -> dict | None: + """Return a slot schema.""" + return {vol.Required("message"): str} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Broadcast a message.""" + hass = intent_obj.hass + ent_reg = er.async_get(hass) + + # Find all assist satellite entities that are not the one invoking the intent + entities = { + entity: entry + for entity in hass.states.async_entity_ids(DOMAIN) + if (entry := ent_reg.async_get(entity)) + and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE + } + + if intent_obj.device_id: + entities = { + entity: entry + for entity, entry in entities.items() + if entry.device_id != intent_obj.device_id + } + + await hass.services.async_call( + DOMAIN, + "announce", + {"message": intent_obj.slots["message"]["value"]}, + blocking=True, + context=intent_obj.context, + target={"entity_id": list(entities)}, + ) + + response = intent_obj.create_response() + response.async_set_speech("Done") + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_results( + success_results=[ + intent.IntentResponseTarget( + type=intent.IntentResponseTargetType.ENTITY, + id=entity, + name=state.name if (state := hass.states.get(entity)) else entity, + ) + for entity in entities + ] + ) + return response diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 468539f5a9d..5fa0da96dc1 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -58,6 +58,7 @@ INTENT_TIMER_STATUS = "HassTimerStatus" INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" INTENT_RESPOND = "HassRespond" +INTENT_BROADCAST = "HassBroadcast" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index 9e9bfd959e6..d75cbd072e0 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -16,7 +16,9 @@ from homeassistant.components.assist_satellite import ( ) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.setup import async_setup_component +from homeassistant.util.ulid import ulid_hex from tests.common import ( MockConfigEntry, @@ -38,11 +40,17 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: class MockAssistSatellite(AssistSatelliteEntity): """Mock Assist Satellite Entity.""" - _attr_name = "Test Entity" - _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE - - def __init__(self) -> None: + def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None: """Initialize the mock entity.""" + self._attr_unique_id = ulid_hex() + self._attr_device_info = DeviceInfo( + { + "name": name, + "identifiers": {(TEST_DOMAIN, self._attr_unique_id)}, + } + ) + self._attr_name = name + self._attr_supported_features = features self.events = [] self.announcements: list[AssistSatelliteAnnouncement] = [] self.config = AssistSatelliteConfiguration( @@ -83,7 +91,19 @@ class MockAssistSatellite(AssistSatelliteEntity): @pytest.fixture def entity() -> MockAssistSatellite: """Mock Assist Satellite Entity.""" - return MockAssistSatellite() + return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE) + + +@pytest.fixture +def entity2() -> MockAssistSatellite: + """Mock a second Assist Satellite Entity.""" + return MockAssistSatellite("Test Entity 2", AssistSatelliteEntityFeature.ANNOUNCE) + + +@pytest.fixture +def entity_no_features() -> MockAssistSatellite: + """Mock a third Assist Satellite Entity.""" + return MockAssistSatellite("Test Entity No features", 0) @pytest.fixture @@ -99,6 +119,8 @@ async def init_components( hass: HomeAssistant, config_entry: ConfigEntry, entity: MockAssistSatellite, + entity2: MockAssistSatellite, + entity_no_features: MockAssistSatellite, ) -> None: """Initialize components.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -125,7 +147,9 @@ async def init_components( async_unload_entry=async_unload_entry_init, ), ) - setup_test_component_platform(hass, AS_DOMAIN, [entity], from_config_entry=True) + setup_test_component_platform( + hass, AS_DOMAIN, [entity, entity2, entity_no_features], from_config_entry=True + ) mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) with mock_config_flow(TEST_DOMAIN, ConfigFlow): diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 884ba36782c..0961c7dfbca 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -63,7 +63,7 @@ async def test_entity_state( ) assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None - assert kwargs["device_id"] is None + assert kwargs["device_id"] is entity.device_entry.id assert kwargs["tts_audio_output"] is None assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( diff --git a/tests/components/assist_satellite/test_intent.py b/tests/components/assist_satellite/test_intent.py new file mode 100644 index 00000000000..27107c7d2e9 --- /dev/null +++ b/tests/components/assist_satellite/test_intent.py @@ -0,0 +1,110 @@ +"""Test assist satellite intents.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.media_source import PlayMedia +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from .conftest import MockAssistSatellite + + +@pytest.fixture +def mock_tts(): + """Mock TTS service.""" + 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", + ), + ), + ): + yield + + +async def test_broadcast_intent( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + entity2: MockAssistSatellite, + entity_no_features: MockAssistSatellite, + mock_tts: None, +) -> None: + """Test we can invoke a broadcast intent.""" + + result = await intent.async_handle( + hass, "test", intent.INTENT_BROADCAST, {"message": {"value": "Hello"}} + ) + + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [ + { + "id": "assist_satellite.test_entity", + "name": "Test Entity", + "type": intent.IntentResponseTargetType.ENTITY, + }, + { + "id": "assist_satellite.test_entity_2", + "name": "Test Entity 2", + "type": intent.IntentResponseTargetType.ENTITY, + }, + ], + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": { + "plain": { + "extra_data": None, + "speech": "Done", + } + }, + } + assert len(entity.announcements) == 1 + assert len(entity2.announcements) == 1 + assert len(entity_no_features.announcements) == 0 + + result = await intent.async_handle( + hass, + "test", + intent.INTENT_BROADCAST, + {"message": {"value": "Hello"}}, + device_id=entity.device_entry.id, + ) + # Broadcast doesn't targets device that triggered it. + assert result.as_dict() == { + "card": {}, + "data": { + "failed": [], + "success": [ + { + "id": "assist_satellite.test_entity_2", + "name": "Test Entity 2", + "type": intent.IntentResponseTargetType.ENTITY, + }, + ], + "targets": [], + }, + "language": "en", + "response_type": "action_done", + "speech": { + "plain": { + "extra_data": None, + "speech": "Done", + } + }, + } + assert len(entity.announcements) == 1 + assert len(entity2.announcements) == 2