Add Conversation command to timers (#118325)

* Add Assist command to timers

* Rename to conversation_command. Execute in timer code.

* Make agent_id optional

* Fix arg

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Michael Hansen 2024-05-28 20:33:31 -05:00 committed by GitHub
parent 615a1eda51
commit d223e1f2ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 95 additions and 2 deletions

View File

@ -96,6 +96,7 @@ async def async_converse(
conversation_id=conversation_id, conversation_id=conversation_id,
device_id=device_id, device_id=device_id,
language=language, language=language,
agent_id=agent_id,
) )
with async_conversation_trace() as trace: with async_conversation_trace() as trace:
trace.add_event( trace.add_event(

View File

@ -354,6 +354,7 @@ class DefaultAgent(ConversationEntity):
language, language,
assistant=DOMAIN, assistant=DOMAIN,
device_id=user_input.device_id, device_id=user_input.device_id,
conversation_agent_id=user_input.agent_id,
) )
except intent.MatchFailedError as match_error: except intent.MatchFailedError as match_error:
# Intent was valid, but no entities matched the constraints. # Intent was valid, but no entities matched the constraints.

View File

@ -188,6 +188,7 @@ async def websocket_hass_agent_debug(
conversation_id=None, conversation_id=None,
device_id=msg.get("device_id"), device_id=msg.get("device_id"),
language=msg.get("language", hass.config.language), language=msg.get("language", hass.config.language),
agent_id=None,
) )
) )
for sentence in msg["sentences"] for sentence in msg["sentences"]

View File

@ -27,6 +27,7 @@ class ConversationInput:
conversation_id: str | None conversation_id: str | None
device_id: str | None device_id: str | None
language: str language: str
agent_id: str | None = None
@dataclass(slots=True) @dataclass(slots=True)

View File

@ -14,7 +14,7 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import ( from homeassistant.helpers import (
area_registry as ar, area_registry as ar,
config_validation as cv, config_validation as cv,
@ -78,6 +78,18 @@ class TimerInfo:
floor_id: str | None = None floor_id: str | None = None
"""Id of floor that the device's area belongs to.""" """Id of floor that the device's area belongs to."""
conversation_command: str | None = None
"""Text of conversation command to execute when timer is finished.
This command must be in the language used to set the timer.
"""
conversation_agent_id: str | None = None
"""Id of the conversation agent used to set the timer.
This agent will be used to execute the conversation command.
"""
@property @property
def seconds_left(self) -> int: def seconds_left(self) -> int:
"""Return number of seconds left on the timer.""" """Return number of seconds left on the timer."""
@ -207,6 +219,8 @@ class TimerManager:
seconds: int | None, seconds: int | None,
language: str, language: str,
name: str | None = None, name: str | None = None,
conversation_command: str | None = None,
conversation_agent_id: str | None = None,
) -> str: ) -> str:
"""Start a timer.""" """Start a timer."""
if not self.is_timer_device(device_id): if not self.is_timer_device(device_id):
@ -235,6 +249,8 @@ class TimerManager:
device_id=device_id, device_id=device_id,
created_at=created_at, created_at=created_at,
updated_at=created_at, updated_at=created_at,
conversation_command=conversation_command,
conversation_agent_id=conversation_agent_id,
) )
# Fill in area/floor info # Fill in area/floor info
@ -410,6 +426,23 @@ class TimerManager:
timer.device_id, timer.device_id,
) )
if timer.conversation_command:
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.conversation import async_converse
self.hass.async_create_background_task(
async_converse(
self.hass,
timer.conversation_command,
conversation_id=None,
context=Context(),
language=timer.language,
agent_id=timer.conversation_agent_id,
device_id=timer.device_id,
),
"timer assist command",
)
def is_timer_device(self, device_id: str) -> bool: def is_timer_device(self, device_id: str) -> bool:
"""Return True if device has been registered to handle timer events.""" """Return True if device has been registered to handle timer events."""
return device_id in self.handlers return device_id in self.handlers
@ -742,6 +775,7 @@ class StartTimerIntentHandler(intent.IntentHandler):
slot_schema = { slot_schema = {
vol.Required(vol.Any("hours", "minutes", "seconds")): cv.positive_int, vol.Required(vol.Any("hours", "minutes", "seconds")): cv.positive_int,
vol.Optional("name"): cv.string, vol.Optional("name"): cv.string,
vol.Optional("conversation_command"): cv.string,
} }
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
@ -772,6 +806,10 @@ class StartTimerIntentHandler(intent.IntentHandler):
if "seconds" in slots: if "seconds" in slots:
seconds = int(slots["seconds"]["value"]) seconds = int(slots["seconds"]["value"])
conversation_command: str | None = None
if "conversation_command" in slots:
conversation_command = slots["conversation_command"]["value"]
timer_manager.start_timer( timer_manager.start_timer(
intent_obj.device_id, intent_obj.device_id,
hours, hours,
@ -779,6 +817,8 @@ class StartTimerIntentHandler(intent.IntentHandler):
seconds, seconds,
language=intent_obj.language, language=intent_obj.language,
name=name, name=name,
conversation_command=conversation_command,
conversation_agent_id=intent_obj.conversation_agent_id,
) )
return intent_obj.create_response() return intent_obj.create_response()

View File

@ -3,7 +3,7 @@
"name": "Wyoming Protocol", "name": "Wyoming Protocol",
"codeowners": ["@balloob", "@synesthesiam"], "codeowners": ["@balloob", "@synesthesiam"],
"config_flow": true, "config_flow": true,
"dependencies": ["assist_pipeline", "intent"], "dependencies": ["assist_pipeline", "intent", "conversation"],
"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",

View File

@ -104,6 +104,7 @@ async def async_handle(
language: str | None = None, language: str | None = None,
assistant: str | None = None, assistant: str | None = None,
device_id: str | None = None, device_id: str | None = None,
conversation_agent_id: str | None = None,
) -> IntentResponse: ) -> IntentResponse:
"""Handle an intent.""" """Handle an intent."""
handler = hass.data.get(DATA_KEY, {}).get(intent_type) handler = hass.data.get(DATA_KEY, {}).get(intent_type)
@ -127,6 +128,7 @@ async def async_handle(
language=language, language=language,
assistant=assistant, assistant=assistant,
device_id=device_id, device_id=device_id,
conversation_agent_id=conversation_agent_id,
) )
try: try:
@ -1156,6 +1158,7 @@ class Intent:
"category", "category",
"assistant", "assistant",
"device_id", "device_id",
"conversation_agent_id",
] ]
def __init__( def __init__(
@ -1170,6 +1173,7 @@ class Intent:
category: IntentCategory | None = None, category: IntentCategory | None = None,
assistant: str | None = None, assistant: str | None = None,
device_id: str | None = None, device_id: str | None = None,
conversation_agent_id: str | None = None,
) -> None: ) -> None:
"""Initialize an intent.""" """Initialize an intent."""
self.hass = hass self.hass = hass
@ -1182,6 +1186,7 @@ class Intent:
self.category = category self.category = category
self.assistant = assistant self.assistant = assistant
self.device_id = device_id self.device_id = device_id
self.conversation_agent_id = conversation_agent_id
@callback @callback
def create_response(self) -> IntentResponse: def create_response(self) -> IntentResponse:

View File

@ -927,6 +927,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non
conversation_id=None, conversation_id=None,
device_id=None, device_id=None,
language=hass.config.language, language=hass.config.language,
agent_id=None,
) )
) )
assert len(calls) == 1 assert len(calls) == 1

View File

@ -555,6 +555,7 @@ async def test_trigger_with_device_id(hass: HomeAssistant) -> None:
conversation_id=None, conversation_id=None,
device_id="my_device", device_id="my_device",
language=hass.config.language, language=hass.config.language,
agent_id=None,
) )
) )
assert result.response.speech["plain"]["speech"] == "my_device" assert result.response.speech["plain"]["speech"] == "my_device"

View File

@ -1421,6 +1421,48 @@ def test_round_time() -> None:
assert _round_time(0, 0, 35) == (0, 0, 30) assert _round_time(0, 0, 35) == (0, 0, 30)
async def test_start_timer_with_conversation_command(
hass: HomeAssistant, init_components
) -> None:
"""Test starting a timer with an conversation command and having it finish."""
device_id = "test_device"
timer_name = "test timer"
test_command = "turn on the lights"
agent_id = "test_agent"
finished_event = asyncio.Event()
@callback
def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None:
if event_type == TimerEventType.FINISHED:
assert timer.conversation_command == test_command
assert timer.conversation_agent_id == agent_id
finished_event.set()
async_register_timer_handler(hass, device_id, handle_timer)
with patch("homeassistant.components.conversation.async_converse") as mock_converse:
result = await intent.async_handle(
hass,
"test",
intent.INTENT_START_TIMER,
{
"name": {"value": timer_name},
"seconds": {"value": 0},
"conversation_command": {"value": test_command},
},
device_id=device_id,
conversation_agent_id=agent_id,
)
assert result.response_type == intent.IntentResponseType.ACTION_DONE
async with asyncio.timeout(1):
await finished_event.wait()
mock_converse.assert_called_once()
assert mock_converse.call_args.args[1] == test_command
async def test_pause_unpause_timer_disambiguate( async def test_pause_unpause_timer_disambiguate(
hass: HomeAssistant, init_components hass: HomeAssistant, init_components
) -> None: ) -> None: