Forward timer events to Wyoming satellites (#118128)

* Add timer tests

* Forward timer events to satellites

* Use config entry for background tasks
This commit is contained in:
Michael Hansen 2024-05-26 16:29:46 -05:00 committed by GitHub
parent 039bc3501b
commit 3766c72ddb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 284 additions and 20 deletions

View File

@ -89,7 +89,7 @@ def _make_satellite(
device_id=device.id, 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): async def update_listener(hass: HomeAssistant, entry: ConfigEntry):

View File

@ -3,10 +3,10 @@
"name": "Wyoming Protocol", "name": "Wyoming Protocol",
"codeowners": ["@balloob", "@synesthesiam"], "codeowners": ["@balloob", "@synesthesiam"],
"config_flow": true, "config_flow": true,
"dependencies": ["assist_pipeline"], "dependencies": ["assist_pipeline", "intent"],
"documentation": "https://www.home-assistant.io/integrations/wyoming", "documentation": "https://www.home-assistant.io/integrations/wyoming",
"integration_type": "service", "integration_type": "service",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["wyoming==1.5.3"], "requirements": ["wyoming==1.5.4"],
"zeroconf": ["_wyoming._tcp.local."] "zeroconf": ["_wyoming._tcp.local."]
} }

View File

@ -11,17 +11,20 @@ from wyoming.asr import Transcribe, Transcript
from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop
from wyoming.client import AsyncTcpClient from wyoming.client import AsyncTcpClient
from wyoming.error import Error from wyoming.error import Error
from wyoming.event import Event
from wyoming.info import Describe, Info from wyoming.info import Describe, Info
from wyoming.ping import Ping, Pong from wyoming.ping import Ping, Pong
from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.pipeline import PipelineStage, RunPipeline
from wyoming.satellite import PauseSatellite, RunSatellite from wyoming.satellite import PauseSatellite, RunSatellite
from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated
from wyoming.tts import Synthesize, SynthesizeVoice from wyoming.tts import Synthesize, SynthesizeVoice
from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detect, Detection 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.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 .const import DOMAIN
from .data import WyomingService from .data import WyomingService
@ -49,10 +52,15 @@ class WyomingSatellite:
"""Remove voice satellite running the Wyoming protocol.""" """Remove voice satellite running the Wyoming protocol."""
def __init__( def __init__(
self, hass: HomeAssistant, service: WyomingService, device: SatelliteDevice self,
hass: HomeAssistant,
config_entry: ConfigEntry,
service: WyomingService,
device: SatelliteDevice,
) -> None: ) -> None:
"""Initialize satellite.""" """Initialize satellite."""
self.hass = hass self.hass = hass
self.config_entry = config_entry
self.service = service self.service = service
self.device = device self.device = device
self.is_running = True self.is_running = True
@ -73,6 +81,10 @@ class WyomingSatellite:
"""Run and maintain a connection to satellite.""" """Run and maintain a connection to satellite."""
_LOGGER.debug("Running satellite task") _LOGGER.debug("Running satellite task")
unregister_timer_handler = intent.async_register_timer_handler(
self.hass, self.device.device_id, self._handle_timer
)
try: try:
while self.is_running: while self.is_running:
try: try:
@ -97,6 +109,8 @@ class WyomingSatellite:
# Wait to restart # Wait to restart
await self.on_restart() await self.on_restart()
finally: finally:
unregister_timer_handler()
# Ensure sensor is off (before stop) # Ensure sensor is off (before stop)
self.device.set_is_active(False) self.device.set_is_active(False)
@ -142,7 +156,8 @@ class WyomingSatellite:
def _send_pause(self) -> None: def _send_pause(self) -> None:
"""Send a pause message to satellite.""" """Send a pause message to satellite."""
if self._client is not None: 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()), self._client.write_event(PauseSatellite().event()),
"pause satellite", "pause satellite",
) )
@ -207,11 +222,11 @@ class WyomingSatellite:
send_ping = True send_ping = True
# Read events and check for pipeline end in parallel # Read events and check for pipeline end in parallel
pipeline_ended_task = self.hass.async_create_background_task( pipeline_ended_task = self.config_entry.async_create_background_task(
self._pipeline_ended_event.wait(), "satellite pipeline ended" self.hass, self._pipeline_ended_event.wait(), "satellite pipeline ended"
) )
client_event_task = self.hass.async_create_background_task( client_event_task = self.config_entry.async_create_background_task(
self._client.read_event(), "satellite event read" self.hass, self._client.read_event(), "satellite event read"
) )
pending = {pipeline_ended_task, client_event_task} pending = {pipeline_ended_task, client_event_task}
@ -222,8 +237,8 @@ class WyomingSatellite:
if send_ping: if send_ping:
# Ensure satellite is still connected # Ensure satellite is still connected
send_ping = False send_ping = False
self.hass.async_create_background_task( self.config_entry.async_create_background_task(
self._send_delayed_ping(), "ping satellite" self.hass, self._send_delayed_ping(), "ping satellite"
) )
async with asyncio.timeout(_PING_TIMEOUT): async with asyncio.timeout(_PING_TIMEOUT):
@ -234,8 +249,12 @@ class WyomingSatellite:
# Pipeline run end event was received # Pipeline run end event was received
_LOGGER.debug("Pipeline finished") _LOGGER.debug("Pipeline finished")
self._pipeline_ended_event.clear() self._pipeline_ended_event.clear()
pipeline_ended_task = self.hass.async_create_background_task( pipeline_ended_task = (
self._pipeline_ended_event.wait(), "satellite pipeline ended" self.config_entry.async_create_background_task(
self.hass,
self._pipeline_ended_event.wait(),
"satellite pipeline ended",
)
) )
pending.add(pipeline_ended_task) pending.add(pipeline_ended_task)
@ -307,8 +326,8 @@ class WyomingSatellite:
_LOGGER.debug("Unexpected event from satellite: %s", client_event) _LOGGER.debug("Unexpected event from satellite: %s", client_event)
# Next event # Next event
client_event_task = self.hass.async_create_background_task( client_event_task = self.config_entry.async_create_background_task(
self._client.read_event(), "satellite event read" self.hass, self._client.read_event(), "satellite event read"
) )
pending.add(client_event_task) pending.add(client_event_task)
@ -348,7 +367,8 @@ class WyomingSatellite:
) )
self._is_pipeline_running = True self._is_pipeline_running = True
self._pipeline_ended_event.clear() 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( assist_pipeline.async_pipeline_from_audio_stream(
self.hass, self.hass,
context=Context(), context=Context(),
@ -544,3 +564,38 @@ class WyomingSatellite:
yield chunk yield chunk
except asyncio.CancelledError: except asyncio.CancelledError:
pass # ignore 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"
)

View File

@ -2894,7 +2894,7 @@ wled==0.18.0
wolf-comm==0.0.8 wolf-comm==0.0.8
# homeassistant.components.wyoming # homeassistant.components.wyoming
wyoming==1.5.3 wyoming==1.5.4
# homeassistant.components.xbox # homeassistant.components.xbox
xbox-webapi==2.0.11 xbox-webapi==2.0.11

View File

@ -2250,7 +2250,7 @@ wled==0.18.0
wolf-comm==0.0.8 wolf-comm==0.0.8
# homeassistant.components.wyoming # homeassistant.components.wyoming
wyoming==1.5.3 wyoming==1.5.4
# homeassistant.components.xbox # homeassistant.components.xbox
xbox-webapi==2.0.11 xbox-webapi==2.0.11

View File

@ -17,6 +17,7 @@ from wyoming.info import Info
from wyoming.ping import Ping, Pong from wyoming.ping import Ping, Pong
from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.pipeline import PipelineStage, RunPipeline
from wyoming.satellite import RunSatellite from wyoming.satellite import RunSatellite
from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated
from wyoming.tts import Synthesize from wyoming.tts import Synthesize
from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detect, Detection 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.components.wyoming.devices import SatelliteDevice
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent as intent_helper
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from . import SATELLITE_INFO, WAKE_WORD_INFO, MockAsyncTcpClient from . import SATELLITE_INFO, WAKE_WORD_INFO, MockAsyncTcpClient
@ -111,6 +113,18 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient):
self.ping_event = asyncio.Event() self.ping_event = asyncio.Event()
self.ping: Ping | None = None 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( self._mic_audio_chunk = AudioChunk(
rate=16000, width=2, channels=1, audio=b"chunk" rate=16000, width=2, channels=1, audio=b"chunk"
).event() ).event()
@ -159,6 +173,18 @@ class SatelliteAsyncTcpClient(MockAsyncTcpClient):
elif Ping.is_type(event.type): elif Ping.is_type(event.type):
self.ping = Ping.from_event(event) self.ping = Ping.from_event(event)
self.ping_event.set() 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: async def read_event(self) -> Event | None:
"""Receive.""" """Receive."""
@ -1083,3 +1109,186 @@ async def test_wake_word_phrase(hass: HomeAssistant) -> None:
assert ( assert (
mock_run_pipeline.call_args.kwargs.get("wake_word_phrase") == "Test Phrase" 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