mirror of
https://github.com/home-assistant/core.git
synced 2026-05-28 19:53:18 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 23f7e9bb86 |
@@ -58,6 +58,7 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN, TIMER_DATA
|
||||
from .timers import (
|
||||
EVENT_TIMER_FINISHED,
|
||||
CancelAllTimersIntentHandler,
|
||||
CancelTimerIntentHandler,
|
||||
DecreaseTimerIntentHandler,
|
||||
@@ -72,6 +73,7 @@ from .timers import (
|
||||
async_device_supports_timers,
|
||||
async_register_timer_handler,
|
||||
)
|
||||
from .timers_api import async_register_timers_api
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -79,6 +81,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"EVENT_TIMER_FINISHED",
|
||||
"TimerEventType",
|
||||
"TimerInfo",
|
||||
"async_device_supports_timers",
|
||||
@@ -157,6 +160,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
intent.async_register(hass, RespondIntentHandler())
|
||||
intent.async_register(hass, GetTemperatureIntent())
|
||||
|
||||
# Websocket API for voice timers
|
||||
async_register_timers_api(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from propcache.api import cached_property
|
||||
@@ -19,9 +19,9 @@ from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
intent,
|
||||
)
|
||||
from homeassistant.util import ulid as ulid_util
|
||||
from homeassistant.util import dt as dt_util, ulid as ulid_util
|
||||
|
||||
from .const import TIMER_DATA
|
||||
from .const import DOMAIN, TIMER_DATA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,6 +30,8 @@ MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched"
|
||||
NO_TIMER_SUPPORT_RESPONSE = "no_timer_support"
|
||||
NO_TIMER_COMMAND_RESPONSE = "no_timer_command"
|
||||
|
||||
EVENT_TIMER_FINISHED = f"{DOMAIN}_timer_finished"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimerInfo:
|
||||
@@ -59,11 +61,11 @@ class TimerInfo:
|
||||
start_seconds: int | None
|
||||
"""Number of seconds the timer should run as given by the user."""
|
||||
|
||||
created_at: int
|
||||
"""Timestamp when timer was created (time.monotonic_ns)"""
|
||||
created_at: datetime
|
||||
"""Timestamp when timer was created (in UTC)"""
|
||||
|
||||
updated_at: int
|
||||
"""Timestamp when timer was last updated (time.monotonic_ns)"""
|
||||
updated_at: datetime
|
||||
"""Timestamp when timer was last updated (in UTC)"""
|
||||
|
||||
language: str
|
||||
"""Language of command used to set the timer."""
|
||||
@@ -92,6 +94,9 @@ class TimerInfo:
|
||||
This agent will be used to execute the conversation command.
|
||||
"""
|
||||
|
||||
finished_event_data: dict[str, Any] | None = None
|
||||
"""Extra data to include in the timer finished event."""
|
||||
|
||||
_created_seconds: int = 0
|
||||
"""Number of seconds on the timer when it was created."""
|
||||
|
||||
@@ -105,8 +110,8 @@ class TimerInfo:
|
||||
if not self.is_active:
|
||||
return self.seconds
|
||||
|
||||
now = time.monotonic_ns()
|
||||
seconds_running = int((now - self.updated_at) / 1e9)
|
||||
now = dt_util.utcnow()
|
||||
seconds_running = int((now - self.updated_at).total_seconds())
|
||||
return max(0, self.seconds - seconds_running)
|
||||
|
||||
@property
|
||||
@@ -126,18 +131,18 @@ class TimerInfo:
|
||||
def cancel(self) -> None:
|
||||
"""Cancel the timer."""
|
||||
self.seconds = 0
|
||||
self.updated_at = time.monotonic_ns()
|
||||
self.updated_at = dt_util.utcnow()
|
||||
self.is_active = False
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause the timer."""
|
||||
self.seconds = self.seconds_left
|
||||
self.updated_at = time.monotonic_ns()
|
||||
self.updated_at = dt_util.utcnow()
|
||||
self.is_active = False
|
||||
|
||||
def unpause(self) -> None:
|
||||
"""Unpause the timer."""
|
||||
self.updated_at = time.monotonic_ns()
|
||||
self.updated_at = dt_util.utcnow()
|
||||
self.is_active = True
|
||||
|
||||
def add_time(self, seconds: int) -> None:
|
||||
@@ -147,14 +152,28 @@ class TimerInfo:
|
||||
"""
|
||||
self.seconds = max(0, self.seconds_left + seconds)
|
||||
self._created_seconds = max(self._created_seconds, self.seconds)
|
||||
self.updated_at = time.monotonic_ns()
|
||||
self.updated_at = dt_util.utcnow()
|
||||
|
||||
def finish(self) -> None:
|
||||
"""Finish the timer."""
|
||||
self.seconds = 0
|
||||
self.updated_at = time.monotonic_ns()
|
||||
self.updated_at = dt_util.utcnow()
|
||||
self.is_active = False
|
||||
|
||||
@property
|
||||
def dict_repr(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the timer."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"seconds": self.seconds,
|
||||
"device_id": self.device_id,
|
||||
"created_at": self.created_at.timestamp(),
|
||||
"updated_at": self.updated_at.timestamp(),
|
||||
"is_active": self.is_active,
|
||||
"has_conversation_command": self.conversation_command is not None,
|
||||
}
|
||||
|
||||
|
||||
class TimerEventType(StrEnum):
|
||||
"""Event type in timer handler."""
|
||||
@@ -227,6 +246,9 @@ class TimerManager:
|
||||
# device_id -> handler
|
||||
self.handlers: dict[str, TimerHandler] = {}
|
||||
|
||||
# websocket API
|
||||
self.listeners: list[TimerHandler] = []
|
||||
|
||||
def register_handler(
|
||||
self, device_id: str, handler: TimerHandler
|
||||
) -> Callable[[], None]:
|
||||
@@ -241,6 +263,18 @@ class TimerManager:
|
||||
|
||||
return unregister
|
||||
|
||||
def register_listener(self, listener: TimerHandler) -> Callable[[], None]:
|
||||
"""Register a timer listener.
|
||||
|
||||
Returns a callable to unregister.
|
||||
"""
|
||||
self.listeners.append(listener)
|
||||
|
||||
def unregister() -> None:
|
||||
self.listeners.remove(listener)
|
||||
|
||||
return unregister
|
||||
|
||||
def start_timer(
|
||||
self,
|
||||
device_id: str | None,
|
||||
@@ -251,6 +285,7 @@ class TimerManager:
|
||||
name: str | None = None,
|
||||
conversation_command: str | None = None,
|
||||
conversation_agent_id: str | None = None,
|
||||
finished_event_data: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Start a timer."""
|
||||
if (not conversation_command) and (device_id is None):
|
||||
@@ -272,7 +307,7 @@ class TimerManager:
|
||||
total_seconds += seconds
|
||||
|
||||
timer_id = ulid_util.ulid_now()
|
||||
created_at = time.monotonic_ns()
|
||||
created_at = dt_util.utcnow()
|
||||
timer = TimerInfo(
|
||||
id=timer_id,
|
||||
name=name,
|
||||
@@ -286,6 +321,7 @@ class TimerManager:
|
||||
updated_at=created_at,
|
||||
conversation_command=conversation_command,
|
||||
conversation_agent_id=conversation_agent_id,
|
||||
finished_event_data=finished_event_data,
|
||||
)
|
||||
|
||||
# Fill in area/floor info
|
||||
@@ -307,6 +343,10 @@ class TimerManager:
|
||||
|
||||
if (not timer.conversation_command) and (timer.device_id in self.handlers):
|
||||
self.handlers[timer.device_id](TimerEventType.STARTED, timer)
|
||||
|
||||
for listener in self.listeners:
|
||||
listener(TimerEventType.STARTED, timer)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Timer started: id=%s, name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s",
|
||||
timer_id,
|
||||
@@ -320,7 +360,7 @@ class TimerManager:
|
||||
return timer_id
|
||||
|
||||
async def _wait_for_timer(
|
||||
self, timer_id: str, seconds: int, updated_at: int
|
||||
self, timer_id: str, seconds: int, updated_at: datetime
|
||||
) -> None:
|
||||
"""Sleep until timer is up. Timer is only finished if it hasn't been updated."""
|
||||
try:
|
||||
@@ -346,6 +386,10 @@ class TimerManager:
|
||||
|
||||
if (not timer.conversation_command) and (timer.device_id in self.handlers):
|
||||
self.handlers[timer.device_id](TimerEventType.CANCELLED, timer)
|
||||
|
||||
for listener in self.listeners:
|
||||
listener(TimerEventType.CANCELLED, timer)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Timer cancelled: id=%s, name=%s, seconds_left=%s, device_id=%s",
|
||||
timer_id,
|
||||
@@ -376,6 +420,9 @@ class TimerManager:
|
||||
if (not timer.conversation_command) and (timer.device_id in self.handlers):
|
||||
self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
|
||||
|
||||
for listener in self.listeners:
|
||||
listener(TimerEventType.UPDATED, timer)
|
||||
|
||||
if seconds > 0:
|
||||
log_verb = "increased"
|
||||
log_seconds = seconds
|
||||
@@ -413,6 +460,10 @@ class TimerManager:
|
||||
|
||||
if (not timer.conversation_command) and (timer.device_id in self.handlers):
|
||||
self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
|
||||
|
||||
for listener in self.listeners:
|
||||
listener(TimerEventType.UPDATED, timer)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Timer paused: id=%s, name=%s, seconds_left=%s, device_id=%s",
|
||||
timer_id,
|
||||
@@ -439,6 +490,10 @@ class TimerManager:
|
||||
|
||||
if (not timer.conversation_command) and (timer.device_id in self.handlers):
|
||||
self.handlers[timer.device_id](TimerEventType.UPDATED, timer)
|
||||
|
||||
for listener in self.listeners:
|
||||
listener(TimerEventType.UPDATED, timer)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Timer unpaused: id=%s, name=%s, seconds_left=%s, device_id=%s",
|
||||
timer_id,
|
||||
@@ -473,6 +528,16 @@ class TimerManager:
|
||||
elif timer.device_id in self.handlers:
|
||||
self.handlers[timer.device_id](TimerEventType.FINISHED, timer)
|
||||
|
||||
for listener in self.listeners:
|
||||
listener(TimerEventType.FINISHED, timer)
|
||||
|
||||
# Fire event
|
||||
finished_event_data: dict[str, Any] = timer.dict_repr
|
||||
if timer.finished_event_data:
|
||||
finished_event_data.update(timer.finished_event_data)
|
||||
|
||||
self.hass.bus.async_fire(EVENT_TIMER_FINISHED, finished_event_data)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Timer finished: id=%s, name=%s, device_id=%s",
|
||||
timer_id,
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Timer websocket API."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import TIMER_DATA
|
||||
from .timers import (
|
||||
TimerEventType,
|
||||
TimerInfo,
|
||||
TimerManager,
|
||||
TimerNotFoundError,
|
||||
TimersNotSupportedError,
|
||||
_get_total_seconds,
|
||||
_round_time,
|
||||
)
|
||||
|
||||
_DURATION_FIELDS: dict[Any, Any] = {
|
||||
vol.Optional("hours"): vol.All(vol.Coerce(int), vol.Range(min=0)),
|
||||
vol.Optional("minutes"): vol.All(vol.Coerce(int), vol.Range(min=0)),
|
||||
vol.Optional("seconds"): vol.All(vol.Coerce(int), vol.Range(min=0)),
|
||||
}
|
||||
_REQUIRE_DURATION = cv.has_at_least_one_key("hours", "minutes", "seconds")
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_timers_api(hass: HomeAssistant) -> None:
|
||||
"""Register the timer websocket API."""
|
||||
websocket_api.async_register_command(hass, websocket_start_timer)
|
||||
websocket_api.async_register_command(hass, websocket_cancel_timer)
|
||||
websocket_api.async_register_command(hass, websocket_pause_timer)
|
||||
websocket_api.async_register_command(hass, websocket_unpause_timer)
|
||||
websocket_api.async_register_command(hass, websocket_increase_timer)
|
||||
websocket_api.async_register_command(hass, websocket_decrease_timer)
|
||||
websocket_api.async_register_command(hass, websocket_timer_status)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_timers)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "intent/timers/start",
|
||||
vol.Required("device_id"): vol.Any(cv.string, None),
|
||||
**_DURATION_FIELDS,
|
||||
vol.Optional("name"): cv.string,
|
||||
vol.Optional("finished_event_data"): dict[str, Any],
|
||||
}
|
||||
),
|
||||
_REQUIRE_DURATION,
|
||||
)
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_start_timer(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Start a timer with a duration and optional name."""
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
|
||||
try:
|
||||
timer_id = timer_manager.start_timer(
|
||||
device_id=msg["device_id"],
|
||||
hours=msg.get("hours"),
|
||||
minutes=msg.get("minutes"),
|
||||
seconds=msg.get("seconds"),
|
||||
language=hass.config.language,
|
||||
name=msg.get("name"),
|
||||
# Passed with EVENT_TIMER_FINISHED
|
||||
finished_event_data=msg.get("finished_event_data"),
|
||||
)
|
||||
connection.send_result(msg["id"], {"timer_id": timer_id})
|
||||
except (TimersNotSupportedError, ValueError) as err:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_SUPPORTED, str(err))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "intent/timers/cancel",
|
||||
vol.Required("timer_id"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_cancel_timer(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Cancel a timer."""
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
timer_id = msg["timer_id"]
|
||||
|
||||
try:
|
||||
timer_manager.cancel_timer(timer_id)
|
||||
connection.send_result(msg["id"], {"timer_id": timer_id})
|
||||
except TimerNotFoundError as err:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "intent/timers/pause",
|
||||
vol.Required("timer_id"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_pause_timer(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Pause a timer."""
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
timer_id = msg["timer_id"]
|
||||
|
||||
try:
|
||||
timer_manager.pause_timer(timer_id)
|
||||
connection.send_result(msg["id"], {"timer_id": timer_id})
|
||||
except TimerNotFoundError as err:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "intent/timers/unpause",
|
||||
vol.Required("timer_id"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_unpause_timer(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Unpause a timer."""
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
timer_id = msg["timer_id"]
|
||||
|
||||
try:
|
||||
timer_manager.unpause_timer(timer_id)
|
||||
connection.send_result(msg["id"], {"timer_id": timer_id})
|
||||
except TimerNotFoundError as err:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "intent/timers/increase",
|
||||
vol.Required("timer_id"): cv.string,
|
||||
**_DURATION_FIELDS,
|
||||
}
|
||||
),
|
||||
_REQUIRE_DURATION,
|
||||
)
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_increase_timer(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Increase a timer's time."""
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
timer_id = msg["timer_id"]
|
||||
|
||||
try:
|
||||
total_seconds = _get_total_seconds(
|
||||
{
|
||||
key: {"value": msg[key]}
|
||||
for key in ("hours", "minutes", "seconds")
|
||||
if key in msg
|
||||
}
|
||||
)
|
||||
timer_manager.add_time(timer_id, total_seconds)
|
||||
connection.send_result(msg["id"], {"timer_id": timer_id})
|
||||
except TimerNotFoundError as err:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "intent/timers/decrease",
|
||||
vol.Required("timer_id"): cv.string,
|
||||
**_DURATION_FIELDS,
|
||||
}
|
||||
),
|
||||
_REQUIRE_DURATION,
|
||||
)
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_decrease_timer(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Decrease a timer's time."""
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
timer_id = msg["timer_id"]
|
||||
|
||||
try:
|
||||
total_seconds = _get_total_seconds(
|
||||
{
|
||||
key: {"value": msg[key]}
|
||||
for key in ("hours", "minutes", "seconds")
|
||||
if key in msg
|
||||
}
|
||||
)
|
||||
timer_manager.remove_time(timer_id, total_seconds)
|
||||
connection.send_result(msg["id"], {"timer_id": timer_id})
|
||||
except TimerNotFoundError as err:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err))
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "intent/timers/status"})
|
||||
@websocket_api.async_response
|
||||
async def websocket_timer_status(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get the status of all timers."""
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
statuses = _get_timer_statuses(timer_manager)
|
||||
connection.send_result(msg["id"], {"timers": statuses})
|
||||
|
||||
|
||||
def _get_timer_statuses(timer_manager: TimerManager) -> list[dict[str, Any]]:
|
||||
"""Get timer statuses for a list of timers."""
|
||||
statuses: list[dict[str, Any]] = []
|
||||
for timer in timer_manager.timers.values():
|
||||
total_seconds = timer.seconds_left
|
||||
|
||||
minutes, seconds = divmod(total_seconds, 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
|
||||
# Get lower-precision time for feedback
|
||||
rounded_hours, rounded_minutes, rounded_seconds = _round_time(
|
||||
hours, minutes, seconds
|
||||
)
|
||||
|
||||
statuses.append(
|
||||
{
|
||||
ATTR_ID: timer.id,
|
||||
ATTR_NAME: timer.name or "",
|
||||
ATTR_DEVICE_ID: timer.device_id or "",
|
||||
"language": timer.language,
|
||||
"start_hours": timer.start_hours or 0,
|
||||
"start_minutes": timer.start_minutes or 0,
|
||||
"start_seconds": timer.start_seconds or 0,
|
||||
"is_active": timer.is_active,
|
||||
"hours_left": hours,
|
||||
"minutes_left": minutes,
|
||||
"seconds_left": seconds,
|
||||
"rounded_hours_left": rounded_hours,
|
||||
"rounded_minutes_left": rounded_minutes,
|
||||
"rounded_seconds_left": rounded_seconds,
|
||||
"total_seconds_left": total_seconds,
|
||||
}
|
||||
)
|
||||
|
||||
return statuses
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "intent/timers/subscribe",
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_subscribe_timers(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.connection.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to intent timers."""
|
||||
msg_id = msg["id"]
|
||||
|
||||
timer_manager: TimerManager = hass.data[TIMER_DATA]
|
||||
|
||||
def send_event(event_type: TimerEventType, timer: TimerInfo) -> None:
|
||||
"""Send a timer event."""
|
||||
connection.send_event(
|
||||
msg_id,
|
||||
{
|
||||
"event_type": event_type,
|
||||
"timer": timer.dict_repr,
|
||||
},
|
||||
)
|
||||
|
||||
connection.subscriptions[msg_id] = timer_manager.register_listener(send_event)
|
||||
|
||||
# Current timers
|
||||
timers = [timer.dict_repr for timer in timer_manager.timers.values()]
|
||||
connection.send_result(msg_id, {"timers": timers})
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Tests for intent timers API."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.intent.timers import (
|
||||
EVENT_TIMER_FINISHED,
|
||||
TimerEventType,
|
||||
TimerInfo,
|
||||
async_register_timer_handler,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
TIMESTAMP = 1735711690.0 # 2025-01-01 06:08:10
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_components(hass: HomeAssistant) -> None:
|
||||
"""Initialize required components for tests."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "conversation", {})
|
||||
assert await async_setup_component(hass, "intent", {})
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2025-01-01 06:08:10")
|
||||
async def test_subscribe_timers_websocket(
|
||||
hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test subscribing to timer updates via websocket."""
|
||||
device_id = "test_device"
|
||||
|
||||
timer_id: str | None = None
|
||||
|
||||
def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None:
|
||||
nonlocal timer_id
|
||||
if event_type == TimerEventType.STARTED:
|
||||
timer_id = timer.id
|
||||
|
||||
async_register_timer_handler(hass, device_id, handle_timer)
|
||||
|
||||
sub_client = await hass_ws_client(hass)
|
||||
control_client = await hass_ws_client(hass)
|
||||
|
||||
await sub_client.send_json_auto_id({"type": "intent/timers/subscribe"})
|
||||
msg = await sub_client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["timers"] == [] # no timers yet
|
||||
|
||||
# Start a timer
|
||||
await control_client.send_json_auto_id(
|
||||
{
|
||||
"type": "intent/timers/start",
|
||||
"device_id": device_id,
|
||||
"minutes": 30,
|
||||
"name": "pizza",
|
||||
"finished_event_data": {"test_key": "test_value"},
|
||||
}
|
||||
)
|
||||
msg = await control_client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
# Verify started event
|
||||
msg = await sub_client.receive_json()
|
||||
assert msg["event"]["event_type"] == "started"
|
||||
assert msg["event"]["timer"] == {
|
||||
"id": timer_id,
|
||||
"name": "pizza",
|
||||
"seconds": 30 * 60, # 30 minutes
|
||||
"device_id": device_id,
|
||||
"created_at": TIMESTAMP,
|
||||
"updated_at": TIMESTAMP,
|
||||
"is_active": True,
|
||||
"has_conversation_command": False,
|
||||
}
|
||||
|
||||
# Check status
|
||||
await control_client.send_json_auto_id({"type": "intent/timers/status"})
|
||||
msg = await control_client.receive_json()
|
||||
assert msg["success"]
|
||||
timers = msg["result"]["timers"]
|
||||
assert len(timers) == 1
|
||||
timer = timers[0]
|
||||
assert timer["id"] == timer_id
|
||||
assert timer["name"] == "pizza"
|
||||
assert timer["is_active"]
|
||||
assert timer["start_hours"] == 0
|
||||
assert timer["start_minutes"] == 30
|
||||
assert timer["start_seconds"] == 0
|
||||
assert timer["total_seconds_left"] > 29 * 60 # 29 minutes
|
||||
for key in (
|
||||
"hours_left",
|
||||
"minutes_left",
|
||||
"seconds_left",
|
||||
"rounded_hours_left",
|
||||
"rounded_minutes_left",
|
||||
"rounded_seconds_left",
|
||||
):
|
||||
assert key in timer
|
||||
assert isinstance(timer[key], int)
|
||||
|
||||
# -----
|
||||
# Pause
|
||||
# -----
|
||||
await control_client.send_json_auto_id(
|
||||
{"type": "intent/timers/pause", "timer_id": timer_id}
|
||||
)
|
||||
msg = await control_client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
# updated event
|
||||
msg = await sub_client.receive_json()
|
||||
assert msg["event"]["event_type"] == "updated"
|
||||
assert msg["event"]["timer"]["id"] == timer_id
|
||||
assert not msg["event"]["timer"]["is_active"]
|
||||
|
||||
# status
|
||||
await control_client.send_json_auto_id({"type": "intent/timers/status"})
|
||||
msg = await control_client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["timers"][0]["id"] == timer_id
|
||||
assert not msg["result"]["timers"][0]["is_active"]
|
||||
|
||||
# --------
|
||||
# Increase
|
||||
# --------
|
||||
await control_client.send_json_auto_id(
|
||||
{"type": "intent/timers/increase", "timer_id": timer_id, "hours": 1}
|
||||
)
|
||||
msg = await control_client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
# updated event
|
||||
msg = await sub_client.receive_json()
|
||||
assert msg["event"]["event_type"] == "updated"
|
||||
assert msg["event"]["timer"]["id"] == timer_id
|
||||
assert msg["event"]["timer"]["seconds"] == (30 + 60) * 60 # 1.5 hours
|
||||
|
||||
# status
|
||||
await control_client.send_json_auto_id({"type": "intent/timers/status"})
|
||||
msg = await control_client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["timers"][0]["id"] == timer_id
|
||||
assert msg["result"]["timers"][0]["hours_left"] == 1
|
||||
|
||||
# -------
|
||||
# Unpause
|
||||
# -------
|
||||
await control_client.send_json_auto_id(
|
||||
{"type": "intent/timers/unpause", "timer_id": timer_id}
|
||||
)
|
||||
msg = await control_client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
# updated event
|
||||
msg = await sub_client.receive_json()
|
||||
assert msg["event"]["event_type"] == "updated"
|
||||
assert msg["event"]["timer"]["id"] == timer_id
|
||||
assert msg["event"]["timer"]["is_active"]
|
||||
|
||||
# status
|
||||
await control_client.send_json_auto_id({"type": "intent/timers/status"})
|
||||
msg = await control_client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["timers"][0]["id"] == timer_id
|
||||
assert msg["result"]["timers"][0]["is_active"]
|
||||
|
||||
timer_finished: dict[str, Any] | None = None
|
||||
timer_finished_ready = asyncio.Event()
|
||||
|
||||
# Subscribe to timer finished event before we remove all its time
|
||||
@callback
|
||||
def handle_finished(event):
|
||||
nonlocal timer_finished
|
||||
timer_finished = event
|
||||
timer_finished_ready.set()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_TIMER_FINISHED, handle_finished)
|
||||
|
||||
# --------
|
||||
# Decrease
|
||||
# --------
|
||||
await control_client.send_json_auto_id(
|
||||
{"type": "intent/timers/decrease", "timer_id": timer_id, "hours": 2}
|
||||
)
|
||||
msg = await control_client.receive_json()
|
||||
assert msg["success"]
|
||||
|
||||
# updated event
|
||||
msg = await sub_client.receive_json()
|
||||
assert msg["event"]["event_type"] == "updated"
|
||||
assert msg["event"]["timer"]["id"] == timer_id
|
||||
assert msg["event"]["timer"]["seconds"] == 0
|
||||
|
||||
# status
|
||||
await control_client.send_json_auto_id({"type": "intent/timers/status"})
|
||||
msg = await control_client.receive_json()
|
||||
assert msg["success"]
|
||||
assert not msg["result"]["timers"] # timer finished
|
||||
|
||||
# ------
|
||||
# Finish
|
||||
# ------
|
||||
async with asyncio.timeout(1):
|
||||
msg = await sub_client.receive_json()
|
||||
|
||||
# Wait for bus event
|
||||
await timer_finished_ready.wait()
|
||||
|
||||
assert msg["event"]["event_type"] == "finished"
|
||||
assert msg["event"]["timer"]["id"] == timer_id
|
||||
|
||||
assert timer_finished
|
||||
assert timer_finished.data["id"] == timer_id
|
||||
|
||||
# Verify custom event data
|
||||
assert timer_finished.data["test_key"] == "test_value"
|
||||
Reference in New Issue
Block a user