Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Hansen 23f7e9bb86 Add timer websocket API 2026-05-12 15:13:47 -05:00
4 changed files with 612 additions and 16 deletions
@@ -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
+81 -16
View File
@@ -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})
+221
View File
@@ -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"