diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 3ef71e2901b..00d587e2bb4 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -89,7 +89,7 @@ def _make_satellite( device_id=device.id, ) - return WyomingSatellite(hass, service, satellite_device) + return WyomingSatellite(hass, config_entry, service, satellite_device) async def update_listener(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 57d49edc853..70768329e60 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -3,10 +3,10 @@ "name": "Wyoming Protocol", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, - "dependencies": ["assist_pipeline"], + "dependencies": ["assist_pipeline", "intent"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "integration_type": "service", "iot_class": "local_push", - "requirements": ["wyoming==1.5.3"], + "requirements": ["wyoming==1.5.4"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index b2f92f765c0..7bbbd3b479a 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -11,17 +11,20 @@ from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop from wyoming.client import AsyncTcpClient from wyoming.error import Error +from wyoming.event import Event from wyoming.info import Describe, Info from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import PauseSatellite, RunSatellite +from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated from wyoming.tts import Synthesize, SynthesizeVoice from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection -from homeassistant.components import assist_pipeline, stt, tts +from homeassistant.components import assist_pipeline, intent, stt, tts from homeassistant.components.assist_pipeline import select as pipeline_select -from homeassistant.core import Context, HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Context, HomeAssistant, callback from .const import DOMAIN from .data import WyomingService @@ -49,10 +52,15 @@ class WyomingSatellite: """Remove voice satellite running the Wyoming protocol.""" def __init__( - self, hass: HomeAssistant, service: WyomingService, device: SatelliteDevice + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + service: WyomingService, + device: SatelliteDevice, ) -> None: """Initialize satellite.""" self.hass = hass + self.config_entry = config_entry self.service = service self.device = device self.is_running = True @@ -73,6 +81,10 @@ class WyomingSatellite: """Run and maintain a connection to satellite.""" _LOGGER.debug("Running satellite task") + unregister_timer_handler = intent.async_register_timer_handler( + self.hass, self.device.device_id, self._handle_timer + ) + try: while self.is_running: try: @@ -97,6 +109,8 @@ class WyomingSatellite: # Wait to restart await self.on_restart() finally: + unregister_timer_handler() + # Ensure sensor is off (before stop) self.device.set_is_active(False) @@ -142,7 +156,8 @@ class WyomingSatellite: def _send_pause(self) -> None: """Send a pause message to satellite.""" if self._client is not None: - self.hass.async_create_background_task( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event(PauseSatellite().event()), "pause satellite", ) @@ -207,11 +222,11 @@ class WyomingSatellite: send_ping = True # Read events and check for pipeline end in parallel - pipeline_ended_task = self.hass.async_create_background_task( - self._pipeline_ended_event.wait(), "satellite pipeline ended" + pipeline_ended_task = self.config_entry.async_create_background_task( + self.hass, self._pipeline_ended_event.wait(), "satellite pipeline ended" ) - client_event_task = self.hass.async_create_background_task( - self._client.read_event(), "satellite event read" + client_event_task = self.config_entry.async_create_background_task( + self.hass, self._client.read_event(), "satellite event read" ) pending = {pipeline_ended_task, client_event_task} @@ -222,8 +237,8 @@ class WyomingSatellite: if send_ping: # Ensure satellite is still connected send_ping = False - self.hass.async_create_background_task( - self._send_delayed_ping(), "ping satellite" + self.config_entry.async_create_background_task( + self.hass, self._send_delayed_ping(), "ping satellite" ) async with asyncio.timeout(_PING_TIMEOUT): @@ -234,8 +249,12 @@ class WyomingSatellite: # Pipeline run end event was received _LOGGER.debug("Pipeline finished") self._pipeline_ended_event.clear() - pipeline_ended_task = self.hass.async_create_background_task( - self._pipeline_ended_event.wait(), "satellite pipeline ended" + pipeline_ended_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._pipeline_ended_event.wait(), + "satellite pipeline ended", + ) ) pending.add(pipeline_ended_task) @@ -307,8 +326,8 @@ class WyomingSatellite: _LOGGER.debug("Unexpected event from satellite: %s", client_event) # Next event - client_event_task = self.hass.async_create_background_task( - self._client.read_event(), "satellite event read" + client_event_task = self.config_entry.async_create_background_task( + self.hass, self._client.read_event(), "satellite event read" ) pending.add(client_event_task) @@ -348,7 +367,8 @@ class WyomingSatellite: ) self._is_pipeline_running = True self._pipeline_ended_event.clear() - self.hass.async_create_background_task( + self.config_entry.async_create_background_task( + self.hass, assist_pipeline.async_pipeline_from_audio_stream( self.hass, context=Context(), @@ -544,3 +564,38 @@ class WyomingSatellite: yield chunk except asyncio.CancelledError: pass # ignore + + @callback + def _handle_timer( + self, event_type: intent.TimerEventType, timer: intent.TimerInfo + ) -> None: + """Forward timer events to satellite.""" + assert self._client is not None + + _LOGGER.debug("Timer event: type=%s, info=%s", event_type, timer) + event: Event | None = None + if event_type == intent.TimerEventType.STARTED: + event = TimerStarted( + id=timer.id, + total_seconds=timer.seconds, + name=timer.name, + start_hours=timer.start_hours, + start_minutes=timer.start_minutes, + start_seconds=timer.start_seconds, + ).event() + elif event_type == intent.TimerEventType.UPDATED: + event = TimerUpdated( + id=timer.id, + is_active=timer.is_active, + total_seconds=timer.seconds, + ).event() + elif event_type == intent.TimerEventType.CANCELLED: + event = TimerCancelled(id=timer.id).event() + elif event_type == intent.TimerEventType.FINISHED: + event = TimerFinished(id=timer.id).event() + + if event is not None: + # Send timer event to satellite + self.config_entry.async_create_background_task( + self.hass, self._client.write_event(event), "wyoming timer event" + ) diff --git a/requirements_all.txt b/requirements_all.txt index 78688d663e2..a4862b9755c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2894,7 +2894,7 @@ wled==0.18.0 wolf-comm==0.0.8 # homeassistant.components.wyoming -wyoming==1.5.3 +wyoming==1.5.4 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef036f6e4a4..f345779920e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2250,7 +2250,7 @@ wled==0.18.0 wolf-comm==0.0.8 # homeassistant.components.wyoming -wyoming==1.5.3 +wyoming==1.5.4 # homeassistant.components.xbox xbox-webapi==2.0.11 diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index a9d1e73e153..cdcecee243c 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -17,6 +17,7 @@ from wyoming.info import Info from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import RunSatellite +from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated from wyoming.tts import Synthesize from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.wake import Detect, Detection @@ -26,6 +27,7 @@ from homeassistant.components.wyoming.data import WyomingService from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent as intent_helper from homeassistant.setup import async_setup_component from . import SATELLITE_INFO, WAKE_WORD_INFO, MockAsyncTcpClient @@ -111,6 +113,18 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): self.ping_event = asyncio.Event() self.ping: Ping | None = None + self.timer_started_event = asyncio.Event() + self.timer_started: TimerStarted | None = None + + self.timer_updated_event = asyncio.Event() + self.timer_updated: TimerUpdated | None = None + + self.timer_cancelled_event = asyncio.Event() + self.timer_cancelled: TimerCancelled | None = None + + self.timer_finished_event = asyncio.Event() + self.timer_finished: TimerFinished | None = None + self._mic_audio_chunk = AudioChunk( rate=16000, width=2, channels=1, audio=b"chunk" ).event() @@ -159,6 +173,18 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient): elif Ping.is_type(event.type): self.ping = Ping.from_event(event) self.ping_event.set() + elif TimerStarted.is_type(event.type): + self.timer_started = TimerStarted.from_event(event) + self.timer_started_event.set() + elif TimerUpdated.is_type(event.type): + self.timer_updated = TimerUpdated.from_event(event) + self.timer_updated_event.set() + elif TimerCancelled.is_type(event.type): + self.timer_cancelled = TimerCancelled.from_event(event) + self.timer_cancelled_event.set() + elif TimerFinished.is_type(event.type): + self.timer_finished = TimerFinished.from_event(event) + self.timer_finished_event.set() async def read_event(self) -> Event | None: """Receive.""" @@ -1083,3 +1109,186 @@ async def test_wake_word_phrase(hass: HomeAssistant) -> None: assert ( mock_run_pipeline.call_args.kwargs.get("wake_word_phrase") == "Test Phrase" ) + + +async def test_timers(hass: HomeAssistant) -> None: + """Test timer events.""" + assert await async_setup_component(hass, "intent", {}) + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.satellite.AsyncTcpClient", + SatelliteAsyncTcpClient([]), + ) as mock_client, + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][ + entry.entry_id + ].satellite.device + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + # Start timer + result = 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=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_started_event.wait() + timer_started = mock_client.timer_started + assert timer_started is not None + assert timer_started.id + assert timer_started.name == "test timer" + assert timer_started.start_hours == 1 + assert timer_started.start_minutes == 2 + assert timer_started.start_seconds == 3 + assert timer_started.total_seconds == (1 * 60 * 60) + (2 * 60) + 3 + + # Pause + mock_client.timer_updated_event.clear() + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_PAUSE_TIMER, + {}, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_updated_event.wait() + timer_updated = mock_client.timer_updated + assert timer_updated is not None + assert timer_updated.id == timer_started.id + assert not timer_updated.is_active + + # Resume + mock_client.timer_updated_event.clear() + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_UNPAUSE_TIMER, + {}, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_updated_event.wait() + timer_updated = mock_client.timer_updated + assert timer_updated is not None + assert timer_updated.id == timer_started.id + assert timer_updated.is_active + + # Add time + mock_client.timer_updated_event.clear() + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_INCREASE_TIMER, + { + "hours": {"value": 2}, + "minutes": {"value": 3}, + "seconds": {"value": 4}, + }, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_updated_event.wait() + timer_updated = mock_client.timer_updated + assert timer_updated is not None + assert timer_updated.id == timer_started.id + assert timer_updated.total_seconds > timer_started.total_seconds + + # Remove time + mock_client.timer_updated_event.clear() + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_DECREASE_TIMER, + { + "hours": {"value": 2}, + "minutes": {"value": 3}, + "seconds": {"value": 5}, # remove 1 extra second + }, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_updated_event.wait() + timer_updated = mock_client.timer_updated + assert timer_updated is not None + assert timer_updated.id == timer_started.id + assert timer_updated.total_seconds < timer_started.total_seconds + + # Cancel + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_CANCEL_TIMER, + {}, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_cancelled_event.wait() + timer_cancelled = mock_client.timer_cancelled + assert timer_cancelled is not None + assert timer_cancelled.id == timer_started.id + + # Start a new timer + mock_client.timer_started_event.clear() + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "name": {"value": "test timer"}, + "minutes": {"value": 1}, + }, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_started_event.wait() + timer_started = mock_client.timer_started + assert timer_started is not None + + # Finished + result = await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_DECREASE_TIMER, + { + "minutes": {"value": 1}, # force finish + }, + device_id=device.device_id, + ) + + assert result.response_type == intent_helper.IntentResponseType.ACTION_DONE + async with asyncio.timeout(1): + await mock_client.timer_finished_event.wait() + timer_finished = mock_client.timer_finished + assert timer_finished is not None + assert timer_finished.id == timer_started.id