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
6 changed files with 284 additions and 20 deletions

View File

@ -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):

View File

@ -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."]
}

View File

@ -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"
)

View File

@ -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

View File

@ -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

View File

@ -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