Add broadcast intent (#135337)

This commit is contained in:
Paulus Schoutsen 2025-01-16 15:41:53 -05:00 committed by GitHub
parent 6e255060c6
commit 762bc7b8d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 211 additions and 7 deletions

View File

@ -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

View File

@ -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)

View File

@ -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):

View File

@ -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(

View File

@ -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