Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions src/haclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,19 +429,48 @@ def scene(self, name: str) -> Scene:

return self._get_or_create("scene", name, _Scene)

def timer(self, name: str) -> Timer:
"""Return the `Timer` for *name*, creating it if needed.
def timer(self, name: str | None = None, *, persistent: bool = False) -> Timer:
"""Return a `Timer`, creating the Python object if needed.

Timers are **ephemeral by default**: the HA helper is created on the
first action and deleted automatically when the timer returns to idle.
Pass ``persistent=True`` to keep the helper alive.

Parameters
----------
name : str
Short object-id or fully-qualified entity id.
name : str or None, optional
Short object-id or fully-qualified entity id. When ``None``
a unique id is generated automatically (only allowed for
ephemeral timers).
persistent : bool, optional
If ``True``, the HA helper is **not** deleted on idle.
Requires an explicit *name*.

Returns
-------
Timer
The timer entity.

Raises
------
ValueError
If ``persistent=True`` and *name* is ``None``.
"""
from .domains.timer import Timer as _Timer
from .domains.timer import _generate_timer_id

if name is None:
if persistent:
raise ValueError("Persistent timers require an explicit name")
name = _generate_timer_id()

return self._get_or_create("timer", name, _Timer)
entity_id = self.registry.resolve("timer", name)
existing = self.registry.get(entity_id)
if existing is not None:
if not isinstance(existing, _Timer):
raise HAClientError(
f"Entity {entity_id} is registered as {type(existing).__name__}, "
f"not {_Timer.__name__}"
)
return existing
return _Timer(entity_id, self, persistent=persistent)
102 changes: 96 additions & 6 deletions src/haclient/domains/timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,44 @@

import datetime
import logging
import uuid
from typing import Any

from ..entity import Entity, ValueChangeHandler

_LOGGER = logging.getLogger(__name__)


def _generate_timer_id() -> str:
"""Generate a short unique object-id for an ephemeral timer.

Returns
-------
str
A string like ``"haclient_a1b2c3d4"``.
"""
return f"haclient_{uuid.uuid4().hex[:8]}"


class Timer(Entity):
"""A Home Assistant timer entity.

Timer states: ``idle``, ``active``, ``paused``.
Actions use intent-specific names: ``start``, ``pause``, ``cancel``,
``finish``, ``change``.

If the timer helper does not yet exist in Home Assistant, it is created
automatically via the ``timer/create`` WebSocket command the first time
an action method (``start``, ``pause``, etc.) is called. This means
users never need to pre-create timer helpers — the library handles it
transparently.
Timers are **ephemeral by default**: the HA helper is created
automatically on the first action and deleted when the timer returns
to idle (natural finish or cancellation). The same ``Timer`` object
can be restarted afterwards — the helper is transparently re-created.

Pass ``persistent=True`` to keep the HA helper alive after the timer
finishes. Persistent timers require an explicit *name*; ephemeral
timers auto-generate one when no name is provided.

Timers that already exist in Home Assistant (e.g. created via the UI)
are never auto-deleted, regardless of the ``persistent`` flag. Only
helpers created by the library are eligible for auto-cleanup.

In addition to the generic ``on_idle`` listener (which fires for both
natural expiry and explicit cancellation), the timer provides
Expand All @@ -33,15 +52,38 @@ class Timer(Entity):
The ``time_remaining`` property computes the live seconds remaining
from ``finishes_at`` when the timer is active, or parses the
``remaining`` attribute when paused.

Parameters
----------
entity_id : str
Fully-qualified entity id (e.g. ``"timer.my_timer"``).
client : HAClient
The owning client instance.
persistent : bool, optional
If ``False`` (default), the HA helper is deleted automatically
when the timer returns to idle.
"""

domain = "timer"

def __init__(self, entity_id: str, client: Any) -> None:
def __init__(self, entity_id: str, client: Any, *, persistent: bool = False) -> None:
super().__init__(entity_id, client)
self._finished_listeners: list[ValueChangeHandler] = []
self._cancelled_listeners: list[ValueChangeHandler] = []
self._ensured: bool = False
self._persistent: bool = persistent
self._created_by_us: bool = False

@property
def persistent(self) -> bool:
"""Whether this timer keeps its HA helper after returning to idle.

Returns
-------
bool
``True`` if the timer is persistent.
"""
return self._persistent

# -- State properties --

Expand Down Expand Up @@ -150,6 +192,52 @@ def time_remaining(self) -> float | None:

# -- Lifecycle --

def _handle_state_changed(
self,
old_state: dict[str, Any] | None,
new_state: dict[str, Any] | None,
) -> None:
"""Update state, dispatch listeners, then auto-delete if ephemeral.

For non-persistent timers **that were created by the library**, the
HA helper is deleted when the timer transitions to ``idle``. Timers
that already existed in Home Assistant (e.g. created via the UI) are
never auto-deleted, even when ``persistent`` is ``False``.

The cleanup runs *after* all user listeners have been dispatched so
that callbacks see the final state.

Parameters
----------
old_state : dict or None
The previous state object.
new_state : dict or None
The new state object.
"""
old_state_str = (old_state or {}).get("state")
super()._handle_state_changed(old_state, new_state)

if (
not self._persistent
and self._created_by_us
and self.state == "idle"
and old_state_str is not None
and old_state_str != "idle"
):
self._schedule_value(self._auto_cleanup, old_state_str, self.state)

async def _auto_cleanup(self, _old: Any, _new: Any) -> None:
"""Delete the HA helper and reset internal state for re-creation.

This is scheduled as a task after user listeners have been invoked.
"""
try:
await self.delete()
except Exception: # noqa: BLE001
_LOGGER.debug("Auto-cleanup of %s failed", self.entity_id, exc_info=True)
self.state = "unknown"
self._created_by_us = False

async def _ensure_exists(self) -> None:
"""Create the timer helper in Home Assistant if it does not exist.

Expand All @@ -171,6 +259,7 @@ async def _ensure_exists(self) -> None:
}
)
self._ensured = True
self._created_by_us = True

async def delete(self) -> None:
"""Delete the timer helper from Home Assistant.
Expand All @@ -192,6 +281,7 @@ async def delete(self) -> None:
}
)
self._ensured = False
self._created_by_us = False

# -- Actions --

Expand Down
Loading
Loading