mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
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:
@ -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):
|
||||
|
@ -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."]
|
||||
}
|
||||
|
@ -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"
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user