From 36d3086864d7fb3d3037e0f06030d547859e1f54 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 13 Aug 2025 17:04:38 -0500 Subject: [PATCH] First pass at acknowledgement --- .../components/assist_pipeline/__init__.py | 23 +++- .../components/assist_pipeline/manifest.json | 2 +- .../components/assist_pipeline/pipeline.py | 109 ++++++++++++++++-- .../assist_pipeline/sounds/acknowledge.mp3 | Bin 0 -> 19832 bytes 4 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/assist_pipeline/sounds/acknowledge.mp3 diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 8f4c6efd355..481f787c8ef 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -3,11 +3,14 @@ from __future__ import annotations from collections.abc import AsyncIterable +from http import HTTPStatus +from pathlib import Path from typing import Any +from aiohttp import web import voluptuous as vol -from homeassistant.components import stt +from homeassistant.components import http, stt from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import chat_session from homeassistant.helpers.typing import ConfigType @@ -86,6 +89,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_setup_pipeline_store(hass) async_register_websocket_api(hass) + hass.http.register_view(DefaultSoundsView(hass)) + return True @@ -133,3 +138,19 @@ async def async_pipeline_from_audio_stream( ) await pipeline_input.validate() await pipeline_input.execute() + + +class DefaultSoundsView(http.HomeAssistantView): + url = f"/api/{DOMAIN}/sounds/{{filename}}" + name = f"api:{DOMAIN}:sounds" + requires_auth = False + + def __init__(self, hass: HomeAssistant) -> None: + self.hass = hass + self.base_dir = Path(__file__).parent / "sounds" + + async def get(self, request: web.Request, filename: str): + if filename not in ("acknowledge.mp3",): + return web.Response(body="Invalid filename", status=HTTPStatus.BAD_REQUEST) + + return web.FileResponse(self.base_dir / filename) diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 3a59d8f87f1..1f61e09aeab 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -3,7 +3,7 @@ "name": "Assist pipeline", "after_dependencies": ["repairs"], "codeowners": ["@balloob", "@synesthesiam"], - "dependencies": ["conversation", "stt", "tts", "wake_word"], + "dependencies": ["conversation", "stt", "tts", "wake_word", "http"], "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", "integration_type": "system", "iot_class": "local_push", diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 0cd593e9666..2781db7a382 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -19,11 +19,24 @@ import wave import hass_nabucasa import voluptuous as vol -from homeassistant.components import conversation, stt, tts, wake_word, websocket_api +from homeassistant.components import ( + conversation, + media_source, + stt, + tts, + wake_word, + websocket_api, +) from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import chat_session, intent +from homeassistant.helpers import ( + chat_session, + device_registry as dr, + entity_registry as er, + intent, + network, +) from homeassistant.helpers.collection import ( CHANGE_UPDATED, CollectionError, @@ -91,6 +104,8 @@ KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = H # Number of response parts to handle before streaming the response STREAM_RESPONSE_CHARS = 60 +DEFAULT_ACKNOWLEDGE_MEDIA_ID = f"/api/{DOMAIN}/sounds/acknowledge.mp3" + def validate_language(data: dict[str, Any]) -> Any: """Validate language settings.""" @@ -412,6 +427,8 @@ class Pipeline: wake_word_entity: str | None wake_word_id: str | None prefer_local_intents: bool = False + acknowledge_same_area: bool = True + acknowledge_media_id: str | None = None id: str = field(default_factory=ulid_util.ulid_now) @@ -436,6 +453,10 @@ class Pipeline: wake_word_entity=data["wake_word_entity"], wake_word_id=data["wake_word_id"], prefer_local_intents=data.get("prefer_local_intents", False), + acknowledge_same_area=data.get("acknowledge_same_area", True), + acknowledge_media_id=data.get( + "acknowledge_media_id", DEFAULT_ACKNOWLEDGE_MEDIA_ID + ), ) def to_json(self) -> dict[str, Any]: @@ -454,6 +475,7 @@ class Pipeline: "wake_word_entity": self.wake_word_entity, "wake_word_id": self.wake_word_id, "prefer_local_intents": self.prefer_local_intents, + "acknowledge_media_id": self.acknowledge_media_id, } @@ -1059,7 +1081,7 @@ class PipelineRun: conversation_id: str, device_id: str | None, conversation_extra_system_prompt: str | None, - ) -> str: + ) -> tuple[str, bool]: """Run intent recognition portion of pipeline. Returns text to speak.""" if self.intent_agent is None or self._conversation_data is None: raise RuntimeError("Recognize intent was not prepared") @@ -1107,6 +1129,7 @@ class PipelineRun: agent_id = self.intent_agent.id processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT + all_same_area = False intent_response: intent.IntentResponse | None = None if not processed_locally and not self._intent_agent_only: # Sentence triggers override conversation agent @@ -1136,7 +1159,8 @@ class PipelineRun: # Try local intents if ( - intent_response is None + self.pipeline.acknowledge_same_area + and intent_response is None and self.pipeline.prefer_local_intents and ( intent_response := await conversation.async_handle_intents( @@ -1280,6 +1304,43 @@ class PipelineRun: if tts_input_stream and self._streamed_response_text: tts_input_stream.put_nowait(None) + intent_response = conversation_result.response + device_registry = dr.async_get(self.hass) + if ( + ( + intent_response.response_type + == intent.IntentResponseType.ACTION_DONE + ) + and intent_response.matched_states + and device_id + and (device := device_registry.async_get(device_id)) + and device.area_id + ): + entity_registry = er.async_get(self.hass) + all_same_area = True + for state in intent_response.matched_states: + entity = entity_registry.async_get(state.entity_id) + if ( + (not entity) + or ( + entity.area_id + and (entity.area_id != device.area_id) + ) + or ( + entity.device_id + and ( + entity_device := device_registry.async_get( + entity.device_id + ) + ) + and entity_device.area_id != device.area_id + ) + ): + all_same_area = False + break + + _LOGGER.error("All same area: %s", all_same_area) + except Exception as src_error: _LOGGER.exception("Unexpected error during intent recognition") raise IntentRecognitionError( @@ -1302,7 +1363,7 @@ class PipelineRun: if conversation_result.continue_conversation: self._conversation_data.continue_conversation_agent = agent_id - return speech + return speech, all_same_area async def prepare_text_to_speech(self) -> None: """Prepare text-to-speech.""" @@ -1370,6 +1431,30 @@ class PipelineRun: PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) ) + async def acknowledge(self, media_id: str, tts_input: str | None) -> None: + self.process_event( + PipelineEvent( + PipelineEventType.TTS_START, + { + "language": self.pipeline.tts_language, + "voice": self.pipeline.tts_voice, + "tts_input": tts_input or "", + }, + ) + ) + + if media_source.is_media_source_id(media_id): + media = await media_source.async_resolve_media(self.hass, media_id, None) + media_id = media.url + else: + media_id = network.get_url(self.hass) + media_id + + tts_output = {"url": media_id} + + self.process_event( + PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output}) + ) + def _capture_chunk(self, audio_bytes: bytes | None) -> None: """Forward audio chunk to various capturing mechanisms.""" if self.debug_recording_queue is not None: @@ -1649,17 +1734,18 @@ class PipelineInput: if self.run.end_stage != PipelineStage.STT: tts_input = self.tts_input + all_same_area = False if current_stage == PipelineStage.INTENT: # intent-recognition assert intent_input is not None - tts_input = await self.run.recognize_intent( + tts_input, all_same_area = await self.run.recognize_intent( intent_input, self.session.conversation_id, self.device_id, self.conversation_extra_system_prompt, ) - if tts_input.strip(): + if all_same_area or tts_input.strip(): current_stage = PipelineStage.TTS else: # Skip TTS @@ -1668,8 +1754,13 @@ class PipelineInput: if self.run.end_stage != PipelineStage.INTENT: # text-to-speech if current_stage == PipelineStage.TTS: - assert tts_input is not None - await self.run.text_to_speech(tts_input) + if all_same_area and self.run.pipeline.acknowledge_media_id: + await self.run.acknowledge( + self.run.pipeline.acknowledge_media_id, tts_input + ) + else: + assert tts_input is not None + await self.run.text_to_speech(tts_input) except PipelineError as err: self.run.process_event( diff --git a/homeassistant/components/assist_pipeline/sounds/acknowledge.mp3 b/homeassistant/components/assist_pipeline/sounds/acknowledge.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..603e79e4f2a65cc29faf92c985f13603ab93ed1b GIT binary patch literal 19832 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 S;tcD$EL~is9K5(2-V6X