From 71d0491bc2aae09317a25286cee7b776dadd5f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A6rkeren?= <164513459+Faerkeren@users.noreply.github.com> Date: Wed, 27 May 2026 17:37:36 -0100 Subject: [PATCH] docs: bring public docstrings up to NumPy completeness (#91) Expand thin one-line docstrings across public APIs to include the NumPy sections users and the mkdocstrings reference rely on. - api.py: HAClient lifecycle methods (connect/close/__aenter__/__aexit__) and accessor properties now document side effects, return shapes, and propagated exceptions. - Domain action shortcuts (switch.on/off/toggle, cover.open/close/stop/ toggle, lock.lock/unlock, fan.on/off/toggle, humidifier.on/off/toggle, valve.open/close/toggle, media_player.play/pause/play_pause/stop/next/ previous/power_on/power_off, timer.pause/cancel/finish, scene.delete, FavoriteItem.play) now document the underlying HA service and the CommandError / HTTPError / TimeoutError / ConnectionClosedError surface, including the lack of capability gating where relevant. - Properties with non-obvious semantics (Light.rgb_color, Switch.is_on, Scene.name/icon, Timer.duration/remaining/finishes_at/time_remaining/ persistent, MediaPlayer.now_playing) now document data shape and computation/coercion behavior. - Light.set_kelvin documents that the value is forwarded verbatim (no min/max clamping) and that out-of-range values surface as CommandError from HA. --- src/haclient/api.py | 167 +++++++++++++++++++++++-- src/haclient/domains/cover.py | 64 +++++++++- src/haclient/domains/fan.py | 49 +++++++- src/haclient/domains/humidifier.py | 48 +++++++- src/haclient/domains/light.py | 26 +++- src/haclient/domains/lock.py | 34 +++++- src/haclient/domains/media_player.py | 175 +++++++++++++++++++++++++-- src/haclient/domains/scene.py | 47 ++++++- src/haclient/domains/switch.py | 59 ++++++++- src/haclient/domains/timer.py | 134 ++++++++++++++++++-- src/haclient/domains/valve.py | 48 +++++++- 11 files changed, 798 insertions(+), 53 deletions(-) diff --git a/src/haclient/api.py b/src/haclient/api.py index 64ff100..9819c71 100644 --- a/src/haclient/api.py +++ b/src/haclient/api.py @@ -263,7 +263,30 @@ def route(event: dict[str, Any]) -> None: # -- Lifecycle ---------------------------------------------------- async def __aenter__(self) -> HAClient: - """Enter the async context manager by calling `connect`.""" + """Enter the async context manager. + + Calls `connect` to open the WebSocket, authenticate, and prime + the state cache. Any exception raised during connect propagates + to the caller; in that case the partially-initialised client is + not entered and `__aexit__` will not run, so callers that pre- + construct the client should still call `close` on failure. + + Returns + ------- + HAClient + ``self``, fully connected and ready for use. + + Raises + ------ + AuthenticationError + If the provided token is rejected by Home Assistant. + ConnectionClosedError + If the WebSocket disconnects before the handshake completes. + TimeoutError + If the initial connect or state-priming request times out. + HTTPError + If the initial REST ``get_states`` call returns an error. + """ await self.connect() return self @@ -273,56 +296,176 @@ async def __aexit__( exc: BaseException | None, tb: TracebackType | None, ) -> None: - """Exit the async context manager by calling `close`.""" + """Exit the async context manager and release all resources. + + Delegates to `close`, which shuts down the WebSocket and REST + adapters. Exceptions raised from the ``with`` block are **not** + suppressed (the method always returns ``None``); any errors + raised by `close` itself surface to the caller. + + Parameters + ---------- + exc_type : type of BaseException or None + Exception class raised inside the ``async with`` block, if + any. + exc : BaseException or None + Exception instance, if any. + tb : TracebackType or None + Associated traceback, if any. + + Notes + ----- + Registered ``on_disconnect`` listeners run as part of the close + sequence. + """ await self.close() async def connect(self) -> None: - """Open the WebSocket and prime the state cache.""" + """Open the WebSocket, authenticate, and prime the state cache. + + Side effects: + + * Opens the WebSocket and performs the auth handshake. + * Issues an initial REST ``get_states`` request and seeds the + `StateStore`. + * Subscribes to ``state_changed`` events so the cache stays + live. + * Starts background reconnect/keepalive tasks (when reconnect + is enabled in the `ConnectionConfig`). + + Raises + ------ + AuthenticationError + If the provided token is rejected. + ConnectionClosedError + If the WebSocket disconnects before the handshake completes. + TimeoutError + If a transport operation exceeds ``request_timeout``. + HTTPError + If the initial REST ``get_states`` call returns an error. + + Notes + ----- + Calling `connect` while already connected is a no-op handled by + the underlying `Connection`. + """ await self._connection.open() async def close(self) -> None: - """Close all transports.""" + """Close all transports and stop background tasks. + + Closes the WebSocket (cancelling reconnect/keepalive tasks and + firing registered ``on_disconnect`` listeners) and the REST + adapter (releasing the owned aiohttp session, if any). An + externally-supplied session is not closed. + + Notes + ----- + Safe to call multiple times; subsequent calls are no-ops. + Errors during shutdown propagate to the caller. + """ await self._connection.close() # -- Public service surface -------------------------------------- @property def config(self) -> ConnectionConfig: - """Return the resolved connection settings.""" + """Resolved connection settings. + + Returns + ------- + ConnectionConfig + The frozen settings object built at construction time + (URLs, token, timeouts, TLS, reconnect, service policy). + """ return self._config @property def base_url(self) -> str: - """Return the configured Home Assistant base URL.""" + """Configured Home Assistant base URL. + + Returns + ------- + str + The REST base URL (e.g. ``"https://homeassistant.local:8123"``). + """ return self._config.base_url @property def connection(self) -> Connection: - """Return the `Connection` lifecycle service.""" + """The `Connection` lifecycle service. + + Returns + ------- + Connection + Owns the open/close lifecycle, dispatches disconnect and + reconnect listeners, and re-primes the state cache after + each successful reconnect. + """ return self._connection @property def events(self) -> EventBus: - """Return the `EventBus`.""" + """The shared `EventBus` for Home Assistant events. + + Returns + ------- + EventBus + User-facing pub/sub façade. Subscriptions take effect + immediately and survive WebSocket reconnects transparently. + """ return self._events @property def services(self) -> ServiceCaller: - """Return the `ServiceCaller`.""" + """The shared `ServiceCaller`. + + Returns + ------- + ServiceCaller + Routes raw service calls over REST or WebSocket according + to the configured `ServicePolicy`. Domain entity actions + ultimately call through this object. + """ return self._services @property def state(self) -> StateStore: - """Return the `StateStore`.""" + """The shared `StateStore`. + + Returns + ------- + StateStore + Owns the live entity cache, exposes the entity registry + used by domain accessors, and dispatches per-entity + listeners on every ``state_changed`` event. + """ return self._state @property def domains(self) -> DomainRegistry: - """Return the active `DomainRegistry`.""" + """Active `DomainRegistry` for this client. + + Returns + ------- + DomainRegistry + Registry of built-in and plugin-discovered `DomainSpec` + entries. Unless an explicit registry was passed at + construction, this is the process-wide shared instance — + mutating it affects every client that shares it. + """ return self._registry def loop(self) -> asyncio.AbstractEventLoop | None: - """Return the running asyncio loop, if any.""" + """Return the running asyncio loop, if any. + + Returns + ------- + asyncio.AbstractEventLoop or None + The most recently observed running loop, or ``None`` when + called outside any running loop and no loop has been + captured yet. + """ return self._clock.loop() def on_disconnect( diff --git a/src/haclient/domains/cover.py b/src/haclient/domains/cover.py index 6b8590f..aaa3ab3 100644 --- a/src/haclient/domains/cover.py +++ b/src/haclient/domains/cover.py @@ -87,15 +87,57 @@ def current_position(self) -> int | None: # -- Actions ------------------------------------------------------ async def open(self) -> None: - """Open the cover fully.""" + """Open the cover fully. + + Invokes the ``cover.open_cover`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("open_cover") async def close(self) -> None: - """Close the cover fully.""" + """Close the cover fully. + + Invokes the ``cover.close_cover`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("close_cover") async def stop(self) -> None: - """Stop movement of the cover.""" + """Stop movement of the cover. + + Invokes the ``cover.stop_cover`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("stop_cover") async def set_position(self, position: int) -> None: @@ -118,7 +160,21 @@ async def set_position(self, position: int) -> None: ) async def toggle(self) -> None: - """Toggle open/close state.""" + """Toggle open/close state. + + Invokes the ``cover.toggle`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("toggle") diff --git a/src/haclient/domains/fan.py b/src/haclient/domains/fan.py index 9b057a0..38b4762 100644 --- a/src/haclient/domains/fan.py +++ b/src/haclient/domains/fan.py @@ -200,15 +200,58 @@ def direction(self) -> str | None: # -- Actions ------------------------------------------------------ async def on(self) -> None: - """Turn the fan on.""" + """Turn the fan on. + + Invokes the ``fan.turn_on`` Home Assistant service. No feature + check is performed. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("turn_on") async def off(self) -> None: - """Turn the fan off.""" + """Turn the fan off. + + Invokes the ``fan.turn_off`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("turn_off") async def toggle(self) -> None: - """Toggle the fan state.""" + """Toggle the fan state. + + Invokes the ``fan.toggle`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("toggle") async def set_percentage(self, percentage: int) -> None: diff --git a/src/haclient/domains/humidifier.py b/src/haclient/domains/humidifier.py index 290dbff..d58b5d3 100644 --- a/src/haclient/domains/humidifier.py +++ b/src/haclient/domains/humidifier.py @@ -134,15 +134,57 @@ def device_class(self) -> str | None: # -- Actions ------------------------------------------------------ async def on(self) -> None: - """Activate the humidifier.""" + """Activate the humidifier. + + Invokes the ``humidifier.turn_on`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("turn_on") async def off(self) -> None: - """Deactivate the humidifier.""" + """Deactivate the humidifier. + + Invokes the ``humidifier.turn_off`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("turn_off") async def toggle(self) -> None: - """Toggle the humidifier state.""" + """Toggle the humidifier state. + + Invokes the ``humidifier.toggle`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("toggle") async def set_humidity(self, humidity: int) -> None: diff --git a/src/haclient/domains/light.py b/src/haclient/domains/light.py index 621bae9..8e41079 100644 --- a/src/haclient/domains/light.py +++ b/src/haclient/domains/light.py @@ -138,7 +138,16 @@ def kelvin(self) -> int | None: @property def rgb_color(self) -> tuple[int, int, int] | None: - """Current RGB color tuple, or ``None``.""" + """Current RGB color tuple. + + Returns + ------- + tuple of int or None + Three-tuple ``(r, g, b)`` with each component coerced to + ``int`` in the range 0--255. ``None`` when the underlying + ``rgb_color`` attribute is missing or is not a 3-element + sequence. + """ value = self.attributes.get("rgb_color") if isinstance(value, (list, tuple)) and len(value) == 3: return (int(value[0]), int(value[1]), int(value[2])) @@ -176,6 +185,21 @@ async def set_kelvin(self, kelvin: int, *, transition: float | None = None) -> N Target color temperature in Kelvin. transition : float or None, optional Seconds for the transition. Forwarded to HA when set. + + Raises + ------ + TypeError + If *kelvin* cannot be coerced to ``int``. + ValueError + If *kelvin* cannot be coerced to ``int`` (e.g. a non-numeric + string). + + Notes + ----- + The value is forwarded to Home Assistant verbatim; this method + does **not** clamp against `min_kelvin` / `max_kelvin`. Values + outside the device's supported range will surface as + `CommandError` from Home Assistant. """ data: dict[str, Any] = {"color_temp_kelvin": int(kelvin)} if transition is not None: diff --git a/src/haclient/domains/lock.py b/src/haclient/domains/lock.py index 4d63dc7..08ce59f 100644 --- a/src/haclient/domains/lock.py +++ b/src/haclient/domains/lock.py @@ -127,11 +127,41 @@ def supports_open(self) -> bool: # -- Actions ------------------------------------------------------ async def lock(self) -> None: - """Engage the lock.""" + """Engage the lock. + + Invokes the ``lock.lock`` Home Assistant service. No feature + check is performed: all locks are expected to support locking. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("lock") async def unlock(self) -> None: - """Release the lock.""" + """Release the lock. + + Invokes the ``lock.unlock`` Home Assistant service. No feature + check is performed: all locks are expected to support unlocking. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("unlock") async def open(self) -> None: diff --git a/src/haclient/domains/media_player.py b/src/haclient/domains/media_player.py index c42a810..01298d4 100644 --- a/src/haclient/domains/media_player.py +++ b/src/haclient/domains/media_player.py @@ -188,7 +188,23 @@ def __init__( self._player = player async def play(self) -> None: - """Play this favorite on its `MediaPlayer`.""" + """Play this favorite on its `MediaPlayer`. + + Delegates to `MediaPlayer.play_media` with the captured + ``media_content_type`` and ``media_content_id``, which invokes + the ``media_player.play_media`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._player.play_media(self.media_content_type, self.media_content_id) def __repr__(self) -> str: @@ -389,33 +405,144 @@ def volume_level(self) -> float | None: @property def now_playing(self) -> NowPlaying: - """Structured snapshot of the currently playing media.""" + """Structured snapshot of the currently playing media. + + Returns + ------- + NowPlaying + A fresh, frozen snapshot built from the entity's current + attributes. The ``entity_picture`` field is resolved against + the REST `base_url` so consumers receive an absolute URL. + Two snapshots can be compared with ``==`` to detect whether + the playing media actually changed. + + Notes + ----- + Every access constructs a new dataclass; no caching or I/O is + performed. The result is independent of any subsequent state + update, so it is safe to retain across event-loop turns. + """ return _now_playing_from_attrs(self.attributes, self._services.rest.base_url) # -- Actions ------------------------------------------------------ async def play(self) -> None: - """Resume / start playback.""" + """Resume / start playback. + + Invokes the ``media_player.media_play`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call (for example, no + media is loaded). + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("media_play") async def pause(self) -> None: - """Pause playback.""" + """Pause playback. + + Invokes the ``media_player.media_pause`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("media_pause") async def play_pause(self) -> None: - """Toggle play/pause.""" + """Toggle play/pause. + + Invokes the ``media_player.media_play_pause`` Home Assistant + service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("media_play_pause") async def stop(self) -> None: - """Stop playback.""" + """Stop playback. + + Invokes the ``media_player.media_stop`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("media_stop") async def next(self) -> None: - """Skip to the next track.""" + """Skip to the next track. + + Invokes the ``media_player.media_next_track`` Home Assistant + service. This method does **not** consult + `NowPlaying.next`; calls to players that do not advertise + skip-next support will surface as `CommandError`. Pre-check + ``self.now_playing.next`` to avoid that. + + Raises + ------ + CommandError + If Home Assistant rejects the service call (e.g. the player + does not support skip-next). + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("media_next_track") async def previous(self) -> None: - """Skip to the previous track.""" + """Skip to the previous track. + + Invokes the ``media_player.media_previous_track`` Home Assistant + service. This method does **not** consult + `NowPlaying.previous`; calls to players that do not advertise + skip-previous support will surface as `CommandError`. Pre-check + ``self.now_playing.previous`` to avoid that. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("media_previous_track") async def set_volume(self, level: float) -> None: @@ -446,11 +573,39 @@ async def mute(self, muted: bool = True) -> None: await self._call_service("volume_mute", {"is_volume_muted": bool(muted)}) async def power_on(self) -> None: - """Power the media player on.""" + """Power the media player on. + + Invokes the ``media_player.turn_on`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("turn_on") async def power_off(self) -> None: - """Power the media player off.""" + """Power the media player off. + + Invokes the ``media_player.turn_off`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("turn_off") async def select_source(self, source: str) -> None: diff --git a/src/haclient/domains/scene.py b/src/haclient/domains/scene.py index 7af30dc..d1ef189 100644 --- a/src/haclient/domains/scene.py +++ b/src/haclient/domains/scene.py @@ -54,13 +54,30 @@ def entity_ids(self) -> list[str]: @property def name(self) -> str | None: - """Human-readable name of the scene.""" + """Human-readable name of the scene. + + Returns + ------- + str or None + The HA ``friendly_name`` attribute, or ``None`` when the + entity does not advertise one. Note that this property + deliberately does not return the scene's ``entity_id`` + or object-id slug. + """ val = self.attributes.get("friendly_name") return str(val) if val is not None else None @property def icon(self) -> str | None: - """Icon identifier for the scene (e.g. ``"mdi:palette"``).""" + """Icon identifier for the scene. + + Returns + ------- + str or None + The raw HA ``icon`` attribute, typically a Material Design + Icons identifier of the form ``"mdi:"``. ``None`` when + the entity does not advertise an icon. + """ val = self.attributes.get("icon") return str(val) if val is not None else None @@ -81,7 +98,31 @@ async def activate(self, *, transition: float | None = None) -> None: await self._call_service("turn_on", data) async def delete(self) -> None: - """Delete this dynamically-created scene.""" + """Delete this dynamically-created scene. + + Invokes the ``scene.delete`` Home Assistant service. This is + only meaningful for scenes created at runtime via + `SceneAccessor.create`; static scenes defined in YAML cannot be + deleted this way and Home Assistant will surface an error. + + Notes + ----- + The local entity object is **not** removed from the registry by + this call. Callers that want to discard the proxy should also + drop their reference. + + Raises + ------ + CommandError + If Home Assistant rejects the call (for example, the scene + is YAML-defined and not deletable). + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("delete") # -- Listener decorators ------------------------------------------ diff --git a/src/haclient/domains/switch.py b/src/haclient/domains/switch.py index 2b5f970..d453e9b 100644 --- a/src/haclient/domains/switch.py +++ b/src/haclient/domains/switch.py @@ -54,21 +54,72 @@ def on_turn_off(self, func: ValueChangeHandler) -> ValueChangeHandler: @property def is_on(self) -> bool: - """Whether the switch is currently on.""" + """Whether the switch is currently on. + + Returns + ------- + bool + ``True`` when the cached entity ``state`` is exactly + ``"on"``; ``False`` for ``"off"`` and any unknown, + unavailable, or transitional value. + """ return self.state == "on" # -- Actions ------------------------------------------------------ async def on(self) -> None: - """Activate the switch.""" + """Activate the switch. + + Invokes the ``switch.turn_on`` Home Assistant service via the + configured routing policy (REST or WebSocket). + + Raises + ------ + CommandError + If Home Assistant rejects the service call (WebSocket path). + HTTPError + If the REST call returns a non-2xx response (REST path). + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("turn_on") async def off(self) -> None: - """Deactivate the switch.""" + """Deactivate the switch. + + Invokes the ``switch.turn_off`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("turn_off") async def toggle(self) -> None: - """Toggle the switch state.""" + """Toggle the switch state. + + Invokes the ``switch.toggle`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("toggle") diff --git a/src/haclient/domains/timer.py b/src/haclient/domains/timer.py index dd46fa4..f2c1072 100644 --- a/src/haclient/domains/timer.py +++ b/src/haclient/domains/timer.py @@ -88,7 +88,18 @@ def __init__( @property def persistent(self) -> bool: - """Whether this timer keeps its HA helper after returning to idle.""" + """Whether this timer keeps its HA helper after returning to idle. + + Returns + ------- + bool + ``True`` for timers created via `TimerAccessor.create` with + ``persistent=True``; ``False`` for ephemeral helpers and + for any timer whose helper was not created by this client. + Only meaningful for timers managed by this library — proxies + for pre-existing HA helpers default to ``False`` but are + never auto-deleted. + """ return self._persistent # -- State properties --------------------------------------------- @@ -110,25 +121,72 @@ def is_idle(self) -> bool: @property def duration(self) -> str | None: - """Configured duration (e.g. ``"0:05:00"``).""" + """Configured duration as a raw HA duration string. + + Returns + ------- + str or None + The HA ``duration`` attribute (e.g. ``"0:05:00"``), or + ``None`` when not reported. The string is **not** parsed + into a :class:`datetime.timedelta`; use `time_remaining` + for a numeric value. + """ val = self.attributes.get("duration") return str(val) if val is not None else None @property def remaining(self) -> str | None: - """Time remaining (e.g. ``"0:04:30"``).""" + """Time remaining as a raw HA duration string. + + Returns + ------- + str or None + The HA ``remaining`` attribute (e.g. ``"0:04:30"``), or + ``None`` when not reported. The string is **not** parsed + into seconds; use `time_remaining` for a numeric value. + """ val = self.attributes.get("remaining") return str(val) if val is not None else None @property def finishes_at(self) -> str | None: - """ISO-8601 datetime when the timer will finish, if active.""" + """ISO-8601 datetime when the timer will finish. + + Returns + ------- + str or None + The raw HA ``finishes_at`` attribute, populated only while + the timer is ``active``. ``None`` for idle or paused + timers, and when the device does not report a finish time. + """ val = self.attributes.get("finishes_at") return str(val) if val is not None else None @property def time_remaining(self) -> float | None: - """Live seconds remaining on the timer, computed from HA attributes.""" + """Live seconds remaining on the timer. + + Returns + ------- + float or None + Computed live on every access: + + * When the timer is ``active``, parses ``finishes_at`` as + an ISO-8601 datetime and returns + ``max(0.0, finishes_at - now_utc)``. + * When the timer is ``paused``, parses the ``remaining`` + duration string. + * Otherwise (``idle``, unavailable, unknown) returns + ``None``. + + ``None`` is also returned when the underlying attribute is + absent or malformed. + + Notes + ----- + The value is not cached; each call may produce a slightly + different result for active timers. No I/O is performed. + """ if self.state == "active": raw = self.attributes.get("finishes_at") if raw is None: @@ -203,15 +261,75 @@ async def start(self, *, duration: str | None = None) -> None: await self._call_service("start", data) async def pause(self) -> None: - """Pause the timer.""" + """Pause the timer. + + Invokes the ``timer.pause`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call (for example, + the timer is not currently active). + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("pause") async def cancel(self) -> None: - """Cancel the timer (returns to idle).""" + """Cancel the timer, returning it to idle. + + Invokes the ``timer.cancel`` Home Assistant service. + + Notes + ----- + Returning to idle from a non-idle state triggers the standard + timer state-change listeners. For ephemeral timers created via + `TimerAccessor.create` with ``persistent=False`` (the default), + this transition also schedules an auto-cleanup that deletes the + HA helper, after which the entity's ``state`` is reset to + ``"unknown"`` and the proxy can be re-ensured by the next + action. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("cancel") async def finish(self) -> None: - """Finish the timer immediately.""" + """Finish the timer immediately. + + Invokes the ``timer.finish`` Home Assistant service. Behaves as + if the configured duration had elapsed: a ``timer.finished`` + event fires and the timer returns to idle. + + Notes + ----- + For ephemeral timers (see `cancel`) the transition to idle + also schedules auto-cleanup of the HA helper. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("finish") async def change(self, *, duration: str) -> None: diff --git a/src/haclient/domains/valve.py b/src/haclient/domains/valve.py index caa252c..f71132a 100644 --- a/src/haclient/domains/valve.py +++ b/src/haclient/domains/valve.py @@ -149,11 +149,39 @@ def supports_stop(self) -> bool: # -- Actions ------------------------------------------------------ async def open(self) -> None: - """Open the valve fully.""" + """Open the valve fully. + + Invokes the ``valve.open_valve`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("open_valve") async def close(self) -> None: - """Close the valve fully.""" + """Close the valve fully. + + Invokes the ``valve.close_valve`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("close_valve") async def stop(self) -> None: @@ -204,7 +232,21 @@ async def set_position(self, position: int) -> None: await self._call_service("set_valve_position", {"position": value}) async def toggle(self) -> None: - """Toggle open/close state.""" + """Toggle open/close state. + + Invokes the ``valve.toggle`` Home Assistant service. + + Raises + ------ + CommandError + If Home Assistant rejects the service call. + HTTPError + If the REST call returns a non-2xx response. + TimeoutError + If the call exceeds the configured request timeout. + ConnectionClosedError + If the WebSocket disconnects mid-call. + """ await self._call_service("toggle")