diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index ef56f3a216445a..f191c36c574397 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -27,6 +27,7 @@ import voluptuous as vol from homeassistant.components import tag, zeroconf +from homeassistant.components.intent import async_register_timer_handler from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, @@ -77,6 +78,7 @@ VoiceAssistantAPIPipeline, VoiceAssistantPipeline, VoiceAssistantUDPPipeline, + handle_timer_event, ) _LOGGER = logging.getLogger(__name__) @@ -517,6 +519,12 @@ async def _on_connnect(self) -> None: handle_stop=self._handle_pipeline_stop, ) ) + if flags & VoiceAssistantFeature.TIMERS: + entry_data.disconnect_callbacks.add( + async_register_timer_handler( + hass, self.device_id, partial(handle_timer_event, cli) + ) + ) cli.subscribe_states(entry_data.async_update_state) cli.subscribe_service_calls(self.async_on_service_call) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a587d5215c2656..37d2e7092e3ddc 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["zeroconf", "tag"], "codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, - "dependencies": ["assist_pipeline", "bluetooth"], + "dependencies": ["assist_pipeline", "bluetooth", "intent"], "dhcp": [ { "registered_devices": true diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index f9f753389edebb..78c2c3837feba4 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -16,6 +16,7 @@ VoiceAssistantCommandFlag, VoiceAssistantEventType, VoiceAssistantFeature, + VoiceAssistantTimerEventType, ) from homeassistant.components import stt, tts @@ -33,6 +34,7 @@ WakeWordDetectionAborted, WakeWordDetectionError, ) +from homeassistant.components.intent.timers import TimerEventType, TimerInfo from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -65,6 +67,17 @@ } ) +_TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventType] = ( + EsphomeEnumMapper( + { + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED: TimerEventType.STARTED, + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED: TimerEventType.UPDATED, + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED: TimerEventType.CANCELLED, + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED: TimerEventType.FINISHED, + } + ) +) + class VoiceAssistantPipeline: """Base abstract pipeline class.""" @@ -438,3 +451,23 @@ def stop(self) -> None: self.started = False self.stop_requested = True + + +def handle_timer_event( + api_client: APIClient, event_type: TimerEventType, timer_info: TimerInfo +) -> None: + """Handle timer events.""" + try: + native_event_type = _TIMER_EVENT_TYPES.from_hass(event_type) + except KeyError: + _LOGGER.debug("Received unknown timer event type: %s", event_type) + return + + api_client.send_voice_assistant_timer_event( + native_event_type, + timer_info.id, + timer_info.name, + timer_info.seconds, + timer_info.seconds_left, + timer_info.is_active, + ) diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index f0014628d43444..c7ba5379174c70 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -1,13 +1,21 @@ """Test ESPHome voice assistant server.""" import asyncio -from collections.abc import Callable +from collections.abc import Awaitable, Callable import io import socket -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import wave -from aioesphomeapi import APIClient, VoiceAssistantEventType +from aioesphomeapi import ( + APIClient, + EntityInfo, + EntityState, + UserService, + VoiceAssistantEventType, + VoiceAssistantFeature, + VoiceAssistantTimerEventType, +) import pytest from homeassistant.components.assist_pipeline import ( @@ -25,6 +33,10 @@ VoiceAssistantUDPPipeline, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent as intent_helper +import homeassistant.helpers.device_registry as dr + +from .conftest import MockESPHomeDevice _TEST_INPUT_TEXT = "This is an input test" _TEST_OUTPUT_TEXT = "This is an output test" @@ -720,3 +732,51 @@ async def async_pipeline_from_audio_stream(*args, **kwargs): ) mock_handle_event.assert_not_called() + + +async def test_timer_events( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that injecting timer events results in the correct api client calls.""" + + 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.TIMERS + }, + ) + dev_reg = dr.async_get(hass) + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "name": {"value": "test timer"}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=dev.id, + ) + + mock_client.send_voice_assistant_timer_event.assert_called_with( + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED, + ANY, + "test timer", + 3723, + 3723, + True, + )