diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 00d587e2bb4225..0d72c2023391f9 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -20,6 +20,7 @@ SATELLITE_PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.SELECT, Platform.SWITCH, Platform.NUMBER, diff --git a/homeassistant/components/wyoming/button.py b/homeassistant/components/wyoming/button.py new file mode 100644 index 00000000000000..dd125097f154a1 --- /dev/null +++ b/homeassistant/components/wyoming/button.py @@ -0,0 +1,43 @@ +"""Wyoming button entities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up button entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + async_add_entities([WyomingSatelliteForceActivate(item.satellite.device)]) + + +class WyomingSatelliteForceActivate(WyomingSatelliteEntity, ButtonEntity): + """Manually activate the satellite instead of using a wake word.""" + + entity_description = ButtonEntityDescription( + key="activate", + translation_key="activat", + ) + + async def async_press(self, **kwargs: Any) -> None: + """Turn on.""" + self._device.force_activate() diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py index 2ca66f3b21a1ef..44f9c77fa86732 100644 --- a/homeassistant/components/wyoming/devices.py +++ b/homeassistant/components/wyoming/devices.py @@ -26,6 +26,7 @@ class SatelliteDevice: _is_active_listener: Callable[[], None] | None = None _is_muted_listener: Callable[[], None] | None = None + _force_activate_listener: Callable[[], None] | None = None _pipeline_listener: Callable[[], None] | None = None _audio_settings_listener: Callable[[], None] | None = None @@ -45,6 +46,12 @@ def set_is_muted(self, muted: bool) -> None: if self._is_muted_listener is not None: self._is_muted_listener() + @callback + def force_activate(self) -> None: + """Request to activate without a wake word.""" + if self._force_activate_listener is not None: + self._force_activate_listener() + @callback def set_pipeline_name(self, pipeline_name: str) -> None: """Inform listeners that pipeline selection has changed.""" @@ -87,6 +94,13 @@ def set_is_muted_listener(self, is_muted_listener: Callable[[], None]) -> None: """Listen for updates to muted status.""" self._is_muted_listener = is_muted_listener + @callback + def set_force_activate_listener( + self, force_activate_listener: Callable[[], None] + ) -> None: + """Listen for force activate requests.""" + self._force_activate_listener = force_activate_listener + @callback def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None: """Listen for updates to pipeline.""" diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 1409925a89496d..66322148cc8abe 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -68,12 +68,14 @@ def __init__( self._client: AsyncTcpClient | None = None self._chunk_converter = AudioChunkConverter(rate=16000, width=2, channels=1) self._is_pipeline_running = False + self._is_force_activated = False self._pipeline_ended_event = asyncio.Event() self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._pipeline_id: str | None = None self._muted_changed_event = asyncio.Event() self.device.set_is_muted_listener(self._muted_changed) + self.device.set_force_activate_listener(self._force_activated) self.device.set_pipeline_listener(self._pipeline_changed) self.device.set_audio_settings_listener(self._audio_settings_changed) @@ -89,7 +91,7 @@ async def run(self) -> None: while self.is_running: try: # Check if satellite has been muted - while self.device.is_muted: + while not self._connection_needed(): _LOGGER.debug("Satellite is muted") await self.on_muted() if not self.is_running: @@ -164,6 +166,10 @@ def _send_pause(self) -> None: def _muted_changed(self) -> None: """Run when device muted status changes.""" + if self._is_force_activated: + # Force activated pipeline running, muted state will be restored when it finishes + return + if self.device.is_muted: # Cancel any running pipeline self._audio_queue.put_nowait(None) @@ -174,6 +180,34 @@ def _muted_changed(self) -> None: self._muted_changed_event.set() self._muted_changed_event.clear() + def _force_activated(self) -> None: + self.config_entry.async_create_background_task( + self.hass, self._run_force_activated(), "force activated pipeline" + ) + + async def _run_force_activated(self) -> None: + self._is_force_activated = True + + if self.device.is_muted: + # We're muted, connect again, which will cause RunSatellite to be sent + self._muted_changed_event.set() + self._muted_changed_event.clear() + + else: + # Not muted, finish the current pipeline, if any, and send RunSatellite + if self._is_pipeline_running: + self._audio_queue.put_nowait(None) + await self._pipeline_ended_event.wait() + + if self._client is not None: + await self._client.write_event( + RunSatellite(start_stage=PipelineStage.ASR).event() + ) + + # Restore mute state when the force activated pipeline finishes + await self._pipeline_ended_event.wait() + self._muted_changed() + def _pipeline_changed(self) -> None: """Run when device pipeline changes.""" @@ -186,9 +220,14 @@ def _audio_settings_changed(self) -> None: # Cancel any running pipeline self._audio_queue.put_nowait(None) + def _connection_needed(self) -> bool: + return self.is_running and ( + self._is_force_activated or not self.device.is_muted + ) + async def _connect_and_loop(self) -> None: """Connect to satellite and run pipelines until an error occurs.""" - while self.is_running and (not self.device.is_muted): + while self._connection_needed(): try: await self._connect() break @@ -202,15 +241,19 @@ async def _connect_and_loop(self) -> None: _LOGGER.debug("Connected to satellite") - if (not self.is_running) or self.device.is_muted: + if not self._connection_needed(): # Run was cancelled or satellite was disabled during connection return # Tell satellite that we're ready - await self._client.write_event(RunSatellite().event()) + if self._is_force_activated: + start_stage = PipelineStage.ASR + else: + start_stage = None + await self._client.write_event(RunSatellite(start_stage=start_stage).event()) # Run until stopped or muted - while self.is_running and (not self.device.is_muted): + while self._connection_needed(): await self._run_pipeline_loop() async def _run_pipeline_loop(self) -> None: @@ -233,7 +276,7 @@ async def _run_pipeline_loop(self) -> None: # Update info from satellite await self._client.write_event(Describe().event()) - while self.is_running and (not self.device.is_muted): + while self._connection_needed(): if send_ping: # Ensure satellite is still connected send_ping = False @@ -413,6 +456,7 @@ def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None: if event.type == assist_pipeline.PipelineEventType.RUN_END: # Pipeline run is complete + self._is_force_activated = False self._is_pipeline_running = False self._pipeline_ended_event.set() self.device.set_is_active(False) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index f2768e45eb8aff..464a863d7bc7f9 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -53,6 +53,11 @@ "name": "Mute" } }, + "button": { + "activat": { + "name": "Activate" + } + }, "number": { "auto_gain": { "name": "Auto gain"