From e05b505fd7afb3729e16063098dbaa01fbb67f3f 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 18:02:01 -0100 Subject: [PATCH] docs: add narrative guides for public workflows Closes #92. Add a Guides section to the documentation site covering the public workflows that previously had no narrative coverage: - Async lifecycle and state priming - Sync wrapper usage and close/context-manager expectations - State and value listeners (both tiers, decorator patterns, removal) - Reconnect and disconnect behavior, including what is re-primed automatically - Service routing as advanced use only, with clear framing that high-level domain methods are the normal path - Custom domains and entry-point plugins, including the register-before-construct ordering pitfall - Per-domain workflow guides for light, switch, climate, cover, media_player, scene, timer, and sensor Also: - Expand the three previously stub reference pages (events, services, plugins) with one-paragraph introductions that point at the new guides. - Update docs/index.md to surface the guides and reframe the service-policy bullet as advanced usage. Restructure the custom-domain example into two fenced blocks (registration + usage) so the registration block remains executable for the docs-example regression test in tests/test_plugins.py. All examples use intent-specific domain methods rather than raw service calls, per the project's design philosophy. --- docs/guides/custom-domains.md | 193 ++++++++++++++++++++++++++++ docs/guides/domains/climate.md | 85 ++++++++++++ docs/guides/domains/cover.md | 85 ++++++++++++ docs/guides/domains/light.md | 116 +++++++++++++++++ docs/guides/domains/media_player.md | 153 ++++++++++++++++++++++ docs/guides/domains/scene.md | 112 ++++++++++++++++ docs/guides/domains/sensor.md | 76 +++++++++++ docs/guides/domains/switch.md | 66 ++++++++++ docs/guides/domains/timer.md | 127 ++++++++++++++++++ docs/guides/index.md | 54 ++++++++ docs/guides/lifecycle.md | 134 +++++++++++++++++++ docs/guides/listeners.md | 140 ++++++++++++++++++++ docs/guides/reconnect.md | 120 +++++++++++++++++ docs/guides/service-routing.md | 132 +++++++++++++++++++ docs/guides/sync-wrapper.md | 124 ++++++++++++++++++ docs/index.md | 26 +++- docs/reference/core/events.md | 6 + docs/reference/core/plugins.md | 6 + docs/reference/core/services.md | 7 + mkdocs.yml | 17 +++ 20 files changed, 1776 insertions(+), 3 deletions(-) create mode 100644 docs/guides/custom-domains.md create mode 100644 docs/guides/domains/climate.md create mode 100644 docs/guides/domains/cover.md create mode 100644 docs/guides/domains/light.md create mode 100644 docs/guides/domains/media_player.md create mode 100644 docs/guides/domains/scene.md create mode 100644 docs/guides/domains/sensor.md create mode 100644 docs/guides/domains/switch.md create mode 100644 docs/guides/domains/timer.md create mode 100644 docs/guides/index.md create mode 100644 docs/guides/lifecycle.md create mode 100644 docs/guides/listeners.md create mode 100644 docs/guides/reconnect.md create mode 100644 docs/guides/service-routing.md create mode 100644 docs/guides/sync-wrapper.md diff --git a/docs/guides/custom-domains.md b/docs/guides/custom-domains.md new file mode 100644 index 0000000..2706825 --- /dev/null +++ b/docs/guides/custom-domains.md @@ -0,0 +1,193 @@ +# Custom domains and plugins + +HaClient ships with typed accessors for the most common Home +Assistant domains: `light`, `switch`, `climate`, `cover`, `fan`, +`humidifier`, `lock`, `media_player`, `scene`, `sensor`, +`binary_sensor`, `air_quality`, `event`, `timer`, `vacuum`, `valve`. + +When you need something we do not ship, the same plugin model that +built-ins use is available to you. + +## In-process registration + +The fastest path is to define an `Entity` subclass and register it +before constructing your client: + +```python +from haclient import HAClient, DomainSpec, Entity, register_domain + +class Sprinkler(Entity): + domain = "sprinkler" + + async def start(self, duration: int) -> None: + await self._call_service("start", {"duration": duration}) + + async def stop(self) -> None: + await self._call_service("stop") + +register_domain(DomainSpec(name="sprinkler", entity_cls=Sprinkler)) + +async with HAClient.from_url(url, token=token) as ha: + sprinkler = ha.domain("sprinkler")["lawn"] + await sprinkler.start(600) +``` + +A few important details: + +- **Register before construction.** Active domains are snapshotted + when `HAClient` is constructed. Registering a new spec on the + shared registry *after* a client exists will not retroactively add + the domain to that client. Register first, then construct. +- **`Entity` subclasses must set `domain`** to the HA domain string + (matching the `name` you give the spec). It is used by + `_call_service` to route service invocations to the right HA + domain. +- **Use `_call_service`** for entity-scoped actions. It automatically + injects `entity_id` and routes through the shared `ServiceCaller`. + +The accessor for a custom domain is reached via +`ha.domain("sprinkler")` or — once you have ensured the domain name +does not collide with a built-in attribute — via attribute access +`ha.sprinkler("lawn")`. + +## Adding listener decorators + +Custom domains can expose typed listener decorators in exactly the +same way the built-ins do: + +```python +from typing import TypeVar +from haclient import Entity + +V = TypeVar("V") # ValueChangeHandler + +class Sprinkler(Entity): + domain = "sprinkler" + + def on_start(self, func: V) -> V: + """Decorator: fire when state transitions to 'running'.""" + return self._register_state_transition_listener("running", func) + + def on_remaining_change(self, func: V) -> V: + """Decorator: fire when the 'remaining' attribute changes.""" + return self._register_attr_listener("remaining", func) +``` + +See the [listeners guide](listeners.md) for the three built-in +categories you can wrap. + +## Collection-level operations + +If your domain has actions that are not tied to a single entity — +analogous to `scene.apply(...)` or `timer.create(...)` — subclass +`DomainAccessor` and attach it to the spec: + +```python +from typing import Any +from haclient import DomainSpec, DomainAccessor, Entity, register_domain + +class IrrigationAccessor(DomainAccessor["Sprinkler"]): + async def run_program(self, program_id: str, *, zones: list[str]) -> None: + await self.factory.services.call( + "sprinkler", + "run_program", + {"program_id": program_id, "zones": zones}, + ) + +register_domain( + DomainSpec( + name="sprinkler", + entity_cls=Sprinkler, + accessor_cls=IrrigationAccessor, + ) +) +``` + +Use `self.factory.services` and `self.factory.state` from inside the +accessor. These are the public hooks; do not reach into the private +underscore-prefixed attributes. + +## Routing custom HA events + +If your domain emits HA events other than `state_changed` (the way +`timer.finished` works for the built-in `timer` domain), declare them +on the spec: + +```python +def on_sprinkler_event(entity: Entity, event_type: str, data: dict[str, Any]) -> None: + print(f"{entity.entity_id} got {event_type}: {data}") + +register_domain( + DomainSpec( + name="sprinkler", + entity_cls=Sprinkler, + event_subscriptions=("sprinkler.zone_finished",), + on_event=on_sprinkler_event, + ) +) +``` + +The client subscribes to each listed event type when it starts and +routes each event to the registered entity via the +`on_event` callback. Events whose `data.entity_id` is unknown are +silently dropped. + +## Shipping a plugin via an entry point + +If you maintain a separate Python package and want HaClient users to +get your domain automatically, expose an entry point under +`haclient.domains` in your package metadata: + +```toml +# pyproject.toml of your plugin package +[project.entry-points."haclient.domains"] +sprinkler = "my_haclient_sprinkler.plugin" +``` + +The module referenced (`my_haclient_sprinkler.plugin`) should +register the spec at import time: + +```python +# my_haclient_sprinkler/plugin.py +from haclient import DomainSpec, Entity, register_domain + +class Sprinkler(Entity): + domain = "sprinkler" + ... + +register_domain(DomainSpec(name="sprinkler", entity_cls=Sprinkler)) +``` + +`HAClient.from_url(..., load_plugins=True)` is the default and +discovers your entry point. Each plugin is loaded inside a try / +except so one broken plugin cannot prevent the rest from loading; +failures are logged but do not raise. + +To opt out of plugin discovery for a specific client (useful in +tests): + +```python +ha = HAClient.from_url(url, token=token, load_plugins=False) +``` + +## Restricting active domains + +If you only need a subset of domains for a particular client, pass +`domains=`: + +```python +ha = HAClient.from_url(url, token=token, domains=["light", "switch"]) +``` + +Unlisted domains are not exposed on the client even though they +remain in the shared registry. + +## Replacing a built-in domain + +This is not supported. Re-registering an existing domain name with a +different `entity_cls` raises `HAClientError`. Re-registering the +same class is a no-op (so importing the same plugin twice is safe). + +If you want to *extend* a built-in domain — for example, add a +custom method on `Light` — subclass it in your own code and use it +from your own helpers; do not try to monkey-patch the registry. diff --git a/docs/guides/domains/climate.md b/docs/guides/domains/climate.md new file mode 100644 index 0000000..53f6c8b --- /dev/null +++ b/docs/guides/domains/climate.md @@ -0,0 +1,85 @@ +# Climate + +The `climate` accessor returns `Climate` entities representing +thermostats, A/C units, and HVAC systems. + +## Reading current state + +```python +climate = ha.climate("living_room") + +print(climate.hvac_mode) # "heat", "cool", "off", ... +print(climate.current_temperature) # sensed temperature, or None +print(climate.target_temperature) # current setpoint, or None +print(climate.hvac_modes) # supported modes, e.g. ["off", "heat", "cool"] +``` + +`hvac_mode` is the entity's `state` string — they are aliases. We +expose it under the intent-specific name because that is what users +actually mean. + +## Setting the setpoint + +```python +await climate.set_temperature(temperature=21.5) +``` + +The method accepts the underlying HA service's full parameter set — +including `target_temp_high` / `target_temp_low` for ranged +thermostats — via the same keyword arguments HA expects. + +## Switching modes + +```python +await climate.set_hvac_mode("heat") +await climate.set_hvac_mode("off") +await climate.set_fan_mode("auto") +``` + +Use values from `climate.hvac_modes` rather than guessing. Different +devices support different mode strings. + +## Reacting to changes + +```python +@climate.on_hvac_mode_change +def mode(old: str | None, new: str | None) -> None: + print(f"hvac {old} -> {new}") + +@climate.on_temperature_change +def measured(old: float | None, new: float | None) -> None: + print(f"current temp now {new}") + +@climate.on_target_temperature_change +def setpoint(old: float | None, new: float | None) -> None: + print(f"setpoint now {new}") +``` + +`on_temperature_change` fires on the sensed temperature; use +`on_target_temperature_change` for setpoint changes (e.g. someone +moved the slider in the HA UI). + +## Common patterns + +### Bump the setpoint + +```python +current = climate.target_temperature or 20.0 +await climate.set_temperature(temperature=current + 0.5) +``` + +### Switch to heat if cold enough + +```python +if (climate.current_temperature or 100) < 18 and climate.hvac_mode == "off": + await climate.set_hvac_mode("heat") + await climate.set_temperature(temperature=20) +``` + +### Log every setpoint change + +```python +@climate.on_target_temperature_change +async def audit(old, new): + await db.insert("hvac_setpoint", entity=climate.entity_id, old=old, new=new) +``` diff --git a/docs/guides/domains/cover.md b/docs/guides/domains/cover.md new file mode 100644 index 0000000..1aaa0e9 --- /dev/null +++ b/docs/guides/domains/cover.md @@ -0,0 +1,85 @@ +# Cover + +The `cover` accessor returns `Cover` entities — garage doors, +blinds, shades, awnings. The HA `cover` domain conflates "open / +close" with "set position to N%"; HaClient exposes both clearly. + +## Reading state + +```python +cover = ha.cover("garage") + +print(cover.is_open) # state == "open" +print(cover.is_closed) # state == "closed" +print(cover.current_position) # 0-100 (closed-open), or None +``` + +`state` is a string and may also be `"opening"`, `"closing"`, or +`"unknown"`. `is_open` / `is_closed` only check the steady-state +strings — neither is `True` mid-motion. + +## Opening, closing, stopping + +```python +await cover.open() +await cover.close() +await cover.stop() # stop a motion in progress +await cover.toggle() # open if closed, close otherwise +``` + +`stop()` is a no-op for covers that do not support stopping. + +## Setting a specific position + +```python +await cover.set_position(50) # half-open +await cover.set_position(100) # fully open (equivalent to open()) +await cover.set_position(0) # fully closed (equivalent to close()) +``` + +`position` is `0..100`. Covers that do not support intermediate +positions will round to the nearest supported value (typically `0` +or `100`). + +## Reacting to changes + +```python +@cover.on_open +def opened(old: str | None, new: str | None) -> None: + print("garage is now open") + +@cover.on_close +def closed(old, new): ... + +@cover.on_position_change +def position(old: int | None, new: int | None) -> None: + print(f"garage position {old} -> {new}") +``` + +`on_open` / `on_close` fire on the *transition into* the open or +closed state — not on each tick of motion. Use `on_position_change` +if you need every percentage update. + +## Common patterns + +### Open only if currently closed + +```python +if cover.is_closed: + await cover.open() +``` + +### Vent — open partially, then close again + +```python +await cover.set_position(20) +await asyncio.sleep(300) +await cover.close() +``` + +### Sync two covers + +```python +target = ha.cover("blind_left").current_position or 100 +await ha.cover("blind_right").set_position(target) +``` diff --git a/docs/guides/domains/light.md b/docs/guides/domains/light.md new file mode 100644 index 0000000..e7d01be --- /dev/null +++ b/docs/guides/domains/light.md @@ -0,0 +1,116 @@ +# Light + +The `light` accessor returns `Light` entities. Lights cover the +single biggest range of capabilities in Home Assistant — brightness, +RGB, color temperature, transitions — and HaClient normalises those +into a small set of intent-specific methods. + +## Basic on / off + +```python +light = ha.light("kitchen") + +await light.on() +await light.off() +await light.toggle() + +if light.is_on: + print("kitchen is on") +``` + +## Brightness + +Brightness is on the Home Assistant 0–255 scale. + +```python +await light.set_brightness(200) +await light.set_brightness(0) # equivalent to light.off() +await light.set_brightness(255, transition=2.0) + +print(light.brightness) # current brightness or None +``` + +Pass `transition` (seconds) to any brightness/color/on/off method to +ramp the change. Lights that do not support transitions ignore it +silently — Home Assistant degrades the request. + +## Color temperature + +Use kelvin directly: + +```python +await light.set_kelvin(2700, transition=1.0) # warm white +await light.set_kelvin(6500) # cool white + +print(light.kelvin, light.min_kelvin, light.max_kelvin) +``` + +`min_kelvin` / `max_kelvin` are reported by Home Assistant per +device; values outside that range are clamped by HA. + +## Color + +```python +await light.set_rgb(255, 128, 0) # orange +await light.set_color(rgb=(255, 0, 128)) # multi-format helper +print(light.rgb_color) # current RGB or None +``` + +`set_color` accepts whichever format the light supports — RGB, +kelvin, hex, named — and lets you pick one at the call site. + +## Reacting to changes + +Each Light entity exposes intent-specific decorators built on the +generic [listener tiers](../listeners.md): + +```python +@light.on_turn_on +def turned_on(old: str | None, new: str | None) -> None: + print("kitchen turned on") + +@light.on_turn_off +def turned_off(old, new): ... + +@light.on_brightness_change +def brightness(old: int | None, new: int | None) -> None: + print(f"brightness {old} -> {new}") + +@light.on_color_change +def color(old, new): ... + +@light.on_kelvin_change +def kelvin(old: int | None, new: int | None) -> None: ... +``` + +Use these instead of `on_state_change` whenever you can — they +filter for you and give you typed values. + +## Common patterns + +### Soft wake-up ramp + +```python +await light.on() +await light.set_brightness(20) +await asyncio.sleep(1) +await light.set_brightness(255, transition=30.0) +``` + +### Match brightness across rooms + +```python +target = ha.light("kitchen").brightness or 200 +for room in ("hallway", "living_room", "bedroom"): + await ha.light(room).set_brightness(target) +``` + +### Only act when state actually changes + +```python +if not light.is_on: + await light.on() +``` + +`light.is_on` and `light.brightness` read from the locally cached +state, so the check is free. diff --git a/docs/guides/domains/media_player.md b/docs/guides/domains/media_player.md new file mode 100644 index 0000000..3ae4c24 --- /dev/null +++ b/docs/guides/domains/media_player.md @@ -0,0 +1,153 @@ +# Media Player + +The `media_player` accessor returns `MediaPlayer` entities for +speakers, TVs, receivers, and software media players. + +## Playback control + +```python +mp = ha.media_player("living_room_speaker") + +await mp.play() +await mp.pause() +await mp.play_pause() # toggle +await mp.stop() +await mp.next() +await mp.previous() +``` + +State helpers: + +```python +print(mp.is_playing) +print(mp.is_paused) +``` + +## Volume and mute + +```python +await mp.set_volume(0.4) # 0.0 - 1.0 +await mp.mute() # mute=True by default +await mp.mute(False) # unmute + +print(mp.volume_level, mp.is_muted) +``` + +Volume is normalised to a float in `[0.0, 1.0]` regardless of the +underlying device's native range. + +## Power + +```python +await mp.power_on() +await mp.power_off() +``` + +## Source / input selection + +```python +await mp.select_source("HDMI 1") +``` + +The available source list is reported by HA on the entity's +attributes; consult `mp.attributes` (or `now_playing`) for the +current set on each device. + +## Now-playing metadata + +`now_playing` returns a typed snapshot of what HA reports: + +```python +np = mp.now_playing +print(np.title, np.artist, np.album) +print(np.duration, np.position) +``` + +Use the dedicated decorator instead of polling: + +```python +@mp.on_media_change +def media(old, new): + print(f"now playing: {mp.now_playing.title}") +``` + +## Browsing and playing media + +For services that take an explicit content reference: + +```python +await mp.play_media( + media_content_type="music", + media_content_id="spotify:playlist:37i9dQZF1DXcBWIGoYBM5M", +) +``` + +For exploring the media library (Spotify, Plex, local files, etc.), +use `browse_media` and `favorites`: + +```python +# Raw browse — same shape HA returns. +tree = await mp.browse_media() +print(tree["children"]) + +# Flattened, playable items only — recursive. +favs = await mp.favorites(max_depth=2, max_nodes=200) +for item in favs: + print(item.title) + +await favs[0].play() +``` + +`favorites()` returns `FavoriteItem` objects, each of which knows +how to play itself against the originating media player. + +## Reacting to changes + +```python +@mp.on_play +def playing(old, new): ... + +@mp.on_pause +def paused(old, new): ... + +@mp.on_stop +def stopped(old, new): ... + +@mp.on_volume_change +def volume(old: float | None, new: float | None) -> None: + print(f"volume {old} -> {new}") + +@mp.on_mute_change +def muted(old: bool | None, new: bool | None) -> None: ... + +@mp.on_media_change +def media(old, new): ... +``` + +## Common patterns + +### Resume only if paused + +```python +if mp.is_paused: + await mp.play() +``` + +### Volume ramp + +```python +import asyncio + +target = 0.6 +step = 0.05 +while (mp.volume_level or 0) < target: + await mp.set_volume(min(target, (mp.volume_level or 0) + step)) + await asyncio.sleep(0.5) +``` + +### Whole-house pause + +```python +for name in ("kitchen_speaker", "living_room_speaker", "bedroom_speaker"): + await ha.media_player(name).pause() +``` diff --git a/docs/guides/domains/scene.md b/docs/guides/domains/scene.md new file mode 100644 index 0000000..4e2de40 --- /dev/null +++ b/docs/guides/domains/scene.md @@ -0,0 +1,112 @@ +# Scene + +Scenes capture a set of entity states under a single name. HaClient +exposes both entity-level activation and collection-level +create / apply operations. + +## Activating an existing scene + +```python +scene = ha.scene("movie_night") +await scene.activate() +await scene.activate(transition=2.0) +``` + +The optional `transition` (seconds) is forwarded to HA and applied +on devices that support it. + +## Scene metadata + +```python +print(scene.name) # friendly name, if any +print(scene.icon) # MDI icon string, if any +print(scene.entity_ids) # entities this scene controls +print(scene.last_activated) # ISO timestamp or None +``` + +## Reacting to activation + +```python +@scene.on_activate +def activated(old: str | None, new: str | None) -> None: + print("movie_night activated") +``` + +`on_activate` fires whenever the scene's state string updates — +typically when HA records a fresh activation timestamp. + +## Applying an ad-hoc scene + +`ha.scene.apply(...)` is a *one-shot* state combination — it does not +create or update a persistent scene helper. Use it when you want the +"scene" semantics (multiple entities, transition, single call) but +do not need to keep the definition around: + +```python +await ha.scene.apply( + { + "light.kitchen": {"state": "on", "brightness": 120}, + "light.hallway": {"state": "on", "brightness": 60}, + "switch.fan": {"state": "off"}, + }, + transition=1.5, +) +``` + +The dict maps entity ids to a `state` (plus any attributes HA +accepts for that entity type). + +## Creating a persistent scene helper + +To register a new scene helper in HA that you can activate later: + +```python +new_scene = await ha.scene.create( + scene_id="late_night", + entities={ + "light.bedroom": {"state": "on", "brightness": 20}, + "light.hallway": {"state": "off"}, + }, +) + +# Later — same accessor reaches it because `entity_cls=Scene` was +# registered against the spec. +await new_scene.activate() +``` + +You can also snapshot the current state of selected entities at +creation time instead of (or in addition to) declaring them +explicitly: + +```python +await ha.scene.create( + scene_id="snapshot_now", + entities={}, + snapshot_entities=["light.kitchen", "light.hallway"], +) +``` + +## Deleting a scene + +```python +await scene.delete() +``` + +Deletes the helper from HA. Only applicable to user-created scene +helpers. + +## Common patterns + +### Toggle scene by tap + +```python +@ha.switch("scene_button").on_turn_on +async def tapped(old, new): + await ha.scene("movie_night").activate() +``` + +### Apply, then revert + +`scene.apply` does not snapshot — to revert you need to capture the +previous values yourself or use `scene.create(..., snapshot_entities=...)` +and `activate()` to swap back. diff --git a/docs/guides/domains/sensor.md b/docs/guides/domains/sensor.md new file mode 100644 index 0000000..387f819 --- /dev/null +++ b/docs/guides/domains/sensor.md @@ -0,0 +1,76 @@ +# Sensor + +The `sensor` accessor returns read-only `Sensor` entities — anything +HA reports a value for: temperature, humidity, power, presence +counts, energy totals, and so on. + +## Reading values + +```python +sensor = ha.sensor("outdoor_temperature") + +print(sensor.value) # float, str, or None +print(sensor.unit_of_measurement) # "°C", "kWh", "%", ... +print(sensor.device_class) # "temperature", "energy", ... +print(sensor.state) # the raw HA state string +print(sensor.attributes) # full attribute dict +``` + +`value` is HaClient's normalised view of the sensor reading: + +- If the state parses as a number, you get a `float`. +- If it does not, you get the raw state string (e.g. `"on"`, + `"home"`). +- If the entity is unavailable or has not arrived yet, you get + `None`. + +For absolute fidelity, read `sensor.state` (always a string) and +`sensor.attributes` directly. + +## Reacting to changes + +```python +@sensor.on_value_change +def changed(old, new): + print(f"{sensor.entity_id}: {old} -> {new}") +``` + +`on_value_change` fires on any state-string change. The handler +receives the raw old/new strings; if you want the normalised numeric +value, read `sensor.value` inside the handler. + +## Common patterns + +### Threshold alerting + +```python +@sensor.on_value_change +async def temp_alert(old, new): + v = sensor.value + if isinstance(v, float) and v > 30.0: + await notify(f"{sensor.entity_id} is hot: {v} {sensor.unit_of_measurement}") +``` + +### Periodic polling fallback + +State arrives via the event stream, so polling is rarely needed. +When you suspect a stale reading after a reconnect or HA restart, +force a single refresh: + +```python +await sensor.async_refresh() +``` + +Or refresh everything at once via `await ha.state.refresh_all()`. + +### Collecting all sensors in a domain + +```python +for entity in ha.sensor.all(): + print(entity.entity_id, entity.value) +``` + +`all()` returns every sensor entity the client has registered so +far. Entities are created lazily when you first ask for them by +name, so `all()` only reflects sensors you (or built-in priming) +have touched — it is not a discovery API. diff --git a/docs/guides/domains/switch.md b/docs/guides/domains/switch.md new file mode 100644 index 0000000..5a103dc --- /dev/null +++ b/docs/guides/domains/switch.md @@ -0,0 +1,66 @@ +# Switch + +Switches are the simplest entities in Home Assistant: an on/off +state with no extra attributes. + +```python +switch = ha.switch("fan_outlet") + +await switch.on() +await switch.off() +await switch.toggle() + +print(switch.is_on) +``` + +## Reacting to changes + +```python +@switch.on_turn_on +def on(old: str | None, new: str | None) -> None: + print("fan outlet on") + +@switch.on_turn_off +def off(old, new): + print("fan outlet off") +``` + +For lower-level access (raw state dicts) use `switch.on_state_change` +— see the [listeners guide](../listeners.md). + +## Common patterns + +### Idempotent toggle + +`toggle()` always flips. If you want "ensure on" or "ensure off", +test first: + +```python +if not switch.is_on: + await switch.on() +``` + +### Coordinated multi-switch action + +```python +for name in ("outlet_a", "outlet_b", "outlet_c"): + await ha.switch(name).off() +``` + +Service calls are serialised through the same WebSocket session, so +this is safe — no need for additional locking. + +### Watching for an external change + +A common automation pattern is to react when a switch is flipped by +a physical button or another script: + +```python +@switch.on_turn_on +async def someone_turned_it_on(old, new): + if not we_are_the_one_who_did_it: + await notify("fan outlet was turned on externally") +``` + +Granular handlers also fire for changes you initiate yourself, so +guard accordingly if you need that distinction. diff --git a/docs/guides/domains/timer.md b/docs/guides/domains/timer.md new file mode 100644 index 0000000..2afda76 --- /dev/null +++ b/docs/guides/domains/timer.md @@ -0,0 +1,127 @@ +# Timer + +The `timer` accessor returns `Timer` entities representing Home +Assistant timer helpers. HaClient adds a typed +`TimerAccessor.create` for runtime creation and forwards every +timer-finished event back to the right entity. + +## Lifecycle of a timer + +Timers move through these states: + +- `idle` — created but not started, or finished/cancelled. +- `active` — counting down. +- `paused` — paused mid-countdown. + +```python +timer = ha.timer("tea") + +print(timer.is_idle, timer.is_active, timer.is_paused) +print(timer.duration) # configured duration as HA string ("HH:MM:SS") +print(timer.remaining) # remaining as HA string +print(timer.time_remaining) # remaining as float seconds +print(timer.finishes_at) # ISO timestamp or None +print(timer.persistent) +``` + +## Starting, pausing, cancelling + +```python +await timer.start() # use configured duration +await timer.start(duration="00:05:00") # override duration + +await timer.pause() +await timer.cancel() # back to idle +await timer.finish() # mark finished immediately + +await timer.change(duration="00:10:00") # change a running timer +``` + +`change` only works on timers that are currently running; HA itself +rejects calls otherwise. + +## Reacting to timer events + +The decorator names match the lifecycle transitions: + +```python +@timer.on_start +def started(old, new): ... + +@timer.on_pause +def paused(old, new): ... + +@timer.on_idle +def idle(old, new): ... + +@timer.on_finished +async def finished(old, new): + await notify("Tea is ready") + +@timer.on_cancelled +def cancelled(old, new): ... +``` + +Two of those — `on_finished` and `on_cancelled` — are wired to the +HA events `timer.finished` and `timer.cancelled` (not just the state +transition), so they fire reliably even on timers that briefly skip +through `idle` between cycles. + +## Creating a runtime timer + +```python +new_timer = await ha.timer.create(duration="00:01:30") +await new_timer.start() +``` + +`create` auto-generates a name when you do not supply one. For a +named, persistent timer (one that survives restarts) you must +provide a `name`: + +```python +laundry = await ha.timer.create( + name="laundry_cycle", + duration="00:45:00", + persistent=True, +) +``` + +Creating a `persistent=True` timer without a `name` raises +`ValueError`. + +## Deleting + +```python +await timer.delete() +``` + +Deletes the helper from HA. Library-created timers should be deleted +when you are done with them, otherwise they accumulate. + +## Common patterns + +### Wait for a timer to finish + +```python +import asyncio + +done = asyncio.Event() + +@timer.on_finished +def _(old, new) -> None: + done.set() + +await timer.start(duration="00:00:30") +await done.wait() +``` + +### Resettable countdown + +```python +async def reset() -> None: + await timer.cancel() + await timer.start(duration="00:10:00") +``` + +`cancel` followed by `start` is the standard way to reset; HA does +not expose a single-call "restart". diff --git a/docs/guides/index.md b/docs/guides/index.md new file mode 100644 index 0000000..47ac134 --- /dev/null +++ b/docs/guides/index.md @@ -0,0 +1,54 @@ +# Guides + +These guides walk through common HaClient workflows. They focus on +*how* to use the client effectively rather than enumerating every +parameter — see the [API Reference](../reference/index.md) for that. + +## Getting started + +- [Async lifecycle and state priming](lifecycle.md) — how `HAClient` + connects, primes its cache, and shuts down cleanly. +- [Sync wrapper](sync-wrapper.md) — when and how to use + `SyncHAClient` from scripts, the REPL, or Jupyter. + +## Reacting to state + +- [State and value listeners](listeners.md) — the two listener tiers, + decorator patterns, and removal. +- [Reconnect and disconnect handling](reconnect.md) — what survives + reconnects automatically and what you need to wire up yourself. + +## Extending the client + +- [Custom domains and plugins](custom-domains.md) — adding a new + domain in-process or via the `haclient.domains` entry point. +- [Service routing (advanced)](service-routing.md) — when to drop to + raw `services.call(...)` and what `prefer="ws" | "rest" | "auto"` + actually does. + +## Domain workflows + +- [Light](domains/light.md) +- [Switch](domains/switch.md) +- [Climate](domains/climate.md) +- [Cover](domains/cover.md) +- [Media Player](domains/media_player.md) +- [Scene](domains/scene.md) +- [Timer](domains/timer.md) +- [Sensor](domains/sensor.md) + +## Design philosophy + +HaClient is **not** a thin wrapper over the Home Assistant REST and +WebSocket APIs. It deliberately reshapes them into something more +consistent and more Pythonic: + +- Use the high-level domain methods (`light.set_brightness(...)`, + `cover.set_position(...)`, `media_player.play()`) as the normal + path. They normalise domain quirks, validate inputs, and pick the + right transport. +- Drop to `client.services.call(...)` only for services that no + domain method covers, or when you need very specific routing. +- Map entity events to domain-specific listener decorators + (`@light.on_brightness_change`, `@timer.on_finished`) before + reaching for the generic `on_state_change`. diff --git a/docs/guides/lifecycle.md b/docs/guides/lifecycle.md new file mode 100644 index 0000000..ccb3310 --- /dev/null +++ b/docs/guides/lifecycle.md @@ -0,0 +1,134 @@ +# Async lifecycle and state priming + +`HAClient` is an async context manager. The normal usage pattern is: + +```python +import asyncio +from haclient import HAClient + +async def main() -> None: + async with HAClient.from_url( + "http://localhost:8123", + token="YOUR_LONG_LIVED_TOKEN", + ) as ha: + light = ha.light("kitchen") + await light.on() + +asyncio.run(main()) +``` + +Entering the `async with` block performs three things, in order: + +1. Opens the WebSocket and completes the Home Assistant auth + handshake. +2. Subscribes to `state_changed` events with **buffering enabled** + so no event is missed during step 3. +3. Issues a single REST `get_states` call to prime the in-memory + state cache, then drains the buffered events on top. + +This priming sequence is what makes `light.state`, `light.attributes`, +and the `is_on` / `brightness` properties readable the moment you +get the entity back — no `await` required to read cached state. + +## Why priming exists + +If the client subscribed to events first and *then* fetched the +snapshot, a `state_changed` event arriving between those two calls +would be lost. If it fetched the snapshot first and *then* +subscribed, the same event would be missed. HaClient subscribes +first, buffers everything until the snapshot lands, applies the +snapshot, then replays the buffer. + +State application is **idempotent** — applying a state dict twice +produces the same result — so replaying a buffered event that was +already reflected in the snapshot is safe. + +## Reading vs refreshing + +State is kept live by the event stream. You do not need to poll. +Once you have an entity, just read its attributes: + +```python +async with HAClient.from_url(url, token=token) as ha: + light = ha.light("kitchen") + print(light.is_on, light.brightness) +``` + +For the rare case where you need to force a REST round trip for a +single entity (e.g. you suspect the event stream missed something +after a network blip), use: + +```python +await light.async_refresh() +``` + +To refresh every registered entity, use the store: + +```python +await ha.state.refresh_all() +``` + +This is also what runs automatically after each successful WebSocket +reconnect — see the [reconnect guide](reconnect.md). + +## Lazy entity creation + +Domain accessors create entities on demand: + +```python +light = ha.light("kitchen") # creates and registers a Light entity +``` + +The entity is registered with the `StateStore` immediately, and its +`state` / `attributes` are populated from the cache if Home Assistant +already knows about `light.kitchen`. If the entity id is unknown, +`state` stays `"unknown"` and `attributes` stays empty until the +first event arrives — there is no error. + +This means you can pre-register an entity that does not yet exist in +Home Assistant and start listening to it; it will hydrate the moment +HA sends its first `state_changed` event. + +## Shutdown + +Exiting the `async with` block calls `close()`, which: + +- Cancels reconnect and keepalive tasks. +- Closes the WebSocket. Registered `on_disconnect` listeners run + here. +- Closes the REST adapter and releases the aiohttp session + *if HaClient created it*. Externally-supplied sessions are left + alone. + +`close()` is safe to call multiple times. + +## Manual control + +If you cannot use `async with` (e.g. you need to construct the +client in one place and connect it in another), use `connect()` and +`close()` directly: + +```python +ha = HAClient.from_url(url, token=token) +await ha.connect() +try: + light = ha.light("kitchen") + await light.on() +finally: + await ha.close() +``` + +## Errors during connect + +`connect()` can raise: + +- `AuthenticationError` — the token was rejected. +- `ConnectionClosedError` — the WebSocket dropped before the + handshake completed. +- `TimeoutError` — a transport call exceeded the configured + `request_timeout`. +- `HTTPError` — the initial REST `get_states` returned an error. + +If you pre-constructed the client and `connect()` raises, you still +own the partially-initialised instance. Call `close()` to release +its resources before discarding it. diff --git a/docs/guides/listeners.md b/docs/guides/listeners.md new file mode 100644 index 0000000..5048bdc --- /dev/null +++ b/docs/guides/listeners.md @@ -0,0 +1,140 @@ +# State and value listeners + +HaClient exposes two listener tiers on every entity. Pick the one +that matches what you actually care about. + +| You want to react to... | Use | +| --- | --- | +| Anything HA reports for this entity (raw dicts) | `entity.on_state_change` | +| The state string changing (e.g. `"off"` → `"on"`) | The domain's `on_*` decorators | +| A specific attribute changing (e.g. `brightness`) | The domain's `on__change` decorators | + +The decorator forms always return the handler unchanged, so they +compose naturally with any callable — sync function, `async def` +function, bound method, lambda. + +## Raw state changes — `on_state_change` + +This is the lowest-level listener. The handler is called with +`(old_state_dict, new_state_dict)`. Either side can be `None` when +the entity appears or disappears from Home Assistant. + +```python +light = ha.light("kitchen") + +@light.on_state_change +def on_change(old: dict | None, new: dict | None) -> None: + print("kitchen ->", new and new.get("state")) +``` + +Async handlers are supported and are scheduled on the client's loop: + +```python +@light.on_state_change +async def on_change(old, new): + await some_async_work(new) +``` + +Use this when you need access to the full HA payload — for example, +you want to inspect every changed attribute, or persist the raw +state dict to a database. For everything else, prefer the granular +listeners below. + +## Granular value listeners + +Each domain exposes intent-specific decorators built on top of three +internal categories: + +- **State string change** — fires whenever the state string changes + at all. Sensor's `on_value_change` and Scene's `on_activate` are + examples. +- **State transition to a specific value** — fires only when the + state transitions *into* a specific string. Light's `on_turn_on`, + Timer's `on_finished`, Lock's `on_jam` are examples. +- **Attribute change** — fires when a specific attribute value + changes. Light's `on_brightness_change`, Climate's + `on_temperature_change`, MediaPlayer's `on_volume_change` are + examples. + +All granular handlers receive `(old_value, new_value)`. They can be +sync or async: + +```python +light = ha.light("kitchen") + +@light.on_turn_on +def turned_on(old: str | None, new: str | None) -> None: + print(f"kitchen turned on (was {old})") + +@light.on_brightness_change +async def brightness_changed(old: int | None, new: int | None) -> None: + await log_brightness(new) +``` + +See each [domain guide](domains/light.md) for the full set of +decorators a particular domain exposes. + +## Why prefer granular listeners + +The granular decorators give you three things `on_state_change` +cannot: + +- **Filtering for free.** `on_brightness_change` only fires when the + brightness attribute actually changes; you do not have to diff + the dicts yourself. +- **Typed values.** Each domain exposes pre-extracted, sensibly-typed + values (e.g. brightness as `int | None`, not a free-form dict + lookup). +- **Stable contracts.** When HA renames an attribute or restructures + a payload, the domain code absorbs the change — your handler + signature stays the same. + +## Removing listeners + +Both tiers have a removal method: + +```python +light.remove_listener(on_change) # raw state-change handler +light.remove_granular_listener(turned_on) # any granular handler +``` + +Unknown handlers are silently ignored, so removing twice is safe. + +`remove_granular_listener` searches in this order: + +1. Attribute listeners +2. State-transition listeners +3. State-value listeners + +It removes the first match. If you have registered the same +function in multiple tiers (rare), call it once per tier. + +## Exceptions in handlers + +Synchronous exceptions raised by a listener are caught, logged, and +swallowed — they will not break the event dispatch loop or prevent +other listeners from running. Async handlers run on the client's +loop; exceptions there are surfaced via the loop's default exception +handler. + +Treat listeners as *best-effort observers*. Anything that must +succeed (e.g. logging a state change to durable storage) should +handle its own retries. + +## Subscribing to non-entity events + +Some Home Assistant events are not tied to a single entity — for +example, `automation_triggered` or custom events fired by your own +automations. Use the `EventBus` directly for those: + +```python +async def on_automation(event: dict) -> None: + print("automation:", event["data"].get("name")) + +ha.events.subscribe("automation_triggered", on_automation) +``` + +Subscriptions automatically re-register on reconnect. See the +[EventBus reference](../reference/core/events.md) for the full +API, including buffering and `subscribe_async` (which awaits the +underlying transport and raises on failure). diff --git a/docs/guides/reconnect.md b/docs/guides/reconnect.md new file mode 100644 index 0000000..f549cf3 --- /dev/null +++ b/docs/guides/reconnect.md @@ -0,0 +1,120 @@ +# Reconnect and disconnect handling + +HaClient is built for long-lived connections. When the WebSocket to +Home Assistant drops — because of a network blip, an HA restart, or +a transient TLS error — the client automatically: + +1. Notifies registered `on_disconnect` handlers. +2. Reconnects in the background (with backoff). +3. Re-authenticates and **re-subscribes to every event type** that + was registered before the drop. +4. Re-primes the entire state cache via REST so your entities + reflect anything that changed while you were offline. +5. Notifies registered `on_reconnect` handlers. + +You do **not** need to: + +- Re-call `subscribe(...)` on the `EventBus`. +- Re-call `on_state_change` / `on_*` on entities. +- Re-fetch state for entities you already hold references to. + +All of that is automatic. + +## Disabling auto-reconnect + +Auto-reconnect is on by default. Disable it via `from_url`: + +```python +ha = HAClient.from_url(url, token=token, reconnect=False) +``` + +With reconnect off, a dropped WebSocket fires `on_disconnect` and +stays down. You become responsible for calling `close()` and +constructing a new client when you want to recover. + +## Registering disconnect / reconnect listeners + +Both methods can be used either imperatively or as decorators. Both +accept sync or async zero-argument callables. + +```python +@ha.on_disconnect +def disconnected() -> None: + metrics.increment("ha.disconnect") + +@ha.on_reconnect +async def reconnected() -> None: + # State has already been re-primed before this runs. + await audit_log("ha-reconnected") +``` + +The same methods exist on `SyncHAClient` and behave identically; the +handlers run on the background loop thread (see the +[sync wrapper guide](sync-wrapper.md)). + +## Timing guarantees + +- `on_disconnect` fires when the underlying WebSocket transitions + from connected to disconnected — including the close performed by + `HAClient.close()`. If your handler must distinguish "shutdown" + from "network failure", check application state, not the client. +- `on_reconnect` fires **after** state has been re-primed. Reading + `entity.state` / `entity.attributes` inside the handler returns + the freshly synced values, not stale pre-drop values. + +If a re-prime fails — for example, the HA REST API is unreachable — +the failure is logged and swallowed; `on_reconnect` still fires. +This keeps your application running even if the very first +post-reconnect refresh is unlucky. Subsequent state changes will +heal the cache through the normal event stream. + +## What happens to in-flight calls + +Any service call that was in flight when the WebSocket dropped will +raise: + +- `ConnectionClosedError` if the call was routed to the WebSocket + and the transport closed mid-call. + +Wrap calls you want to survive transient drops in your own retry +logic. The reconnect machinery is about *keeping the client live*, +not about implicitly retrying user calls. Implicit retries would +cause double execution of side-effectful services such as +`switch.toggle`. + +## Pattern: wait for the next reconnect + +For scripts that want to block until the client has recovered: + +```python +import asyncio + +reconnected = asyncio.Event() + +@ha.on_reconnect +def _() -> None: + reconnected.set() + +# ... later, when you know you need fresh state: +await reconnected.wait() +reconnected.clear() +``` + +## Pattern: degrade UI on disconnect + +For interactive applications, dual handlers give you the on / off +edges: + +```python +@ha.on_disconnect +def _() -> None: + ui.set_banner("Reconnecting to Home Assistant…") + +@ha.on_reconnect +def _() -> None: + ui.clear_banner() +``` + +Because `on_reconnect` runs after the re-prime, this gives users +a single moment when "everything is consistent again" rather than a +race between the banner clearing and entities updating. diff --git a/docs/guides/service-routing.md b/docs/guides/service-routing.md new file mode 100644 index 0000000..28876e8 --- /dev/null +++ b/docs/guides/service-routing.md @@ -0,0 +1,132 @@ +# Service routing (advanced) + +> This guide is for **advanced use only**. Most users should never +> need to touch `client.services.call(...)` or the `prefer=` knob. +> If you find yourself reaching for raw service calls, first check +> whether a domain method already covers your use case. + +## The normal path: domain methods + +The supported, stable way to invoke Home Assistant is through the +domain entity methods: + +```python +await ha.light("kitchen").set_brightness(200, transition=2.0) +await ha.media_player("living_room").play() +await ha.cover("garage").set_position(50) +await ha.scene.apply({"light.ceiling": {"state": "on", "brightness": 120}}) +``` + +These methods: + +- Validate inputs (e.g. brightness is `0..255`, volume is `0.0..1.0`). +- Normalise domain quirks (e.g. Light's many color formats, Cover's + position direction). +- Use the right transport for each service. +- Keep working when Home Assistant restructures its underlying + service payloads. + +If you can express what you want through a domain method, do that. + +## When you might need raw service calls + +There are exactly three legitimate reasons to drop to +`client.services.call(...)`: + +1. **The service is not exposed by any domain.** Some integrations + (e.g. custom user integrations, niche third-party domains) have + no built-in HaClient coverage. +2. **You need a one-off call to an HA-wide service** that is not + entity-scoped — for example `homeassistant.restart` or + `persistent_notification.create`. +3. **You need explicit transport control for a single call** that + the default policy would route the wrong way. + +For everything else, file a feature request — coverage gaps are bugs. + +## Calling a service directly + +```python +await ha.services.call( + "notify", + "mobile_app_my_phone", + {"title": "Doorbell", "message": "Someone is at the door"}, +) +``` + +The signature is: + +```python +async def call( + domain: str, + service: str, + data: dict | None = None, + *, + prefer: ServicePolicy | None = None, +) -> Any +``` + +`data` is the raw service payload. There is no entity-id injection +— if the service needs an entity, you put it in `data` yourself. + +## The `prefer` policy + +`ServicePolicy` is a `Literal["ws", "rest", "auto"]` that controls +which transport carries a service call: + +- `"auto"` (default) — use the WebSocket when it is connected, + otherwise fall back to REST. This is what you want in nearly + every case. +- `"ws"` — always use the WebSocket. Raises `ConnectionClosedError` + if the WS is not connected. Use this only when you specifically + need the WS-only behaviour of a service. +- `"rest"` — always use REST. Use this when you must guarantee the + call goes over HTTP — for example, in a script that runs before + the WebSocket has fully come up, or when you are debugging + routing. + +You can set the default for a client at construction: + +```python +ha = HAClient.from_url(url, token=token, service_policy="rest") +``` + +Or override per call: + +```python +await ha.services.call( + "homeassistant", "restart", prefer="rest" +) +``` + +## Why the abstraction exists + +Home Assistant has two service-call transports with subtly different +behaviours: + +- The **WebSocket** path returns service results inline. It is the + only transport for some HA features (e.g. `media_player/browse_media` + results, `timer/create` responses). +- The **REST** path is fire-and-forget for most services; the + response just confirms the request was accepted. It works without + an active WS subscription, which makes it useful for one-shot + scripts. + +The `ServiceCaller` makes this choice explicit and testable instead +of hiding it. Domain methods already pick correctly — they ask for +`prefer="ws"` whenever they need an actual response payload, and +otherwise let `"auto"` decide. + +## Anti-patterns + +- **Wrapping domain methods in service calls.** Do not write + `services.call("light", "turn_on", {"entity_id": "light.kitchen", + "brightness": 200})` instead of + `ha.light("kitchen").set_brightness(200)`. You lose validation, + type safety, and forward compatibility. +- **Polling the WS state via raw service calls.** State is already + cached and updated by the event stream. Read `entity.state` / + `entity.attributes`. +- **Hard-coding `prefer="rest"` for everything.** The default + `"auto"` policy is correct for nearly all workloads and will + transparently use the more efficient WS path when available. diff --git a/docs/guides/sync-wrapper.md b/docs/guides/sync-wrapper.md new file mode 100644 index 0000000..097c082 --- /dev/null +++ b/docs/guides/sync-wrapper.md @@ -0,0 +1,124 @@ +# Sync wrapper + +`SyncHAClient` is a blocking facade over `HAClient` aimed at scripts, +the REPL, and Jupyter notebooks — anywhere `async/await` would be +inconvenient. + +```python +from haclient import SyncHAClient + +with SyncHAClient.from_url( + "http://localhost:8123", + token="YOUR_TOKEN", +) as ha: + light = ha.light("kitchen") + light.set_brightness(200) + print(light.is_on, light.brightness) +``` + +The same surface is available — domain accessors, entity properties, +listener registration — but every async method becomes a blocking +call. + +## How it works + +Construction spawns a dedicated daemon thread (`haclient-sync-loop`) +that owns a single `asyncio` event loop. The underlying `HAClient` is +created on that loop. Every method call from your synchronous code is +forwarded to the loop via `asyncio.run_coroutine_threadsafe` and the +calling thread blocks on the result. + +You never have to think about the loop directly — `ha.light("kitchen")` +returns a proxy that auto-blocks on async methods and transparently +returns plain values for sync ones. + +## Construction + +Use `from_url` exactly as you would for the async client: + +```python +ha = SyncHAClient.from_url( + "http://localhost:8123", + token="YOUR_TOKEN", + reconnect=True, + request_timeout=30.0, + service_policy="auto", +) +``` + +There is **no `from_async_client(...)` factory** — the sync wrapper +always owns its own loop and its own underlying `HAClient`. Trying to +share one async client across both interfaces will not work and is +not supported. + +## Context manager and `close()` + +The `with` statement is the recommended usage. It guarantees both +sides of the bring-up: + +- `__enter__` calls `connect()`, which blocks until the background + loop has authenticated, primed state, and is ready. +- `__exit__` calls `close()`, which shuts down the underlying client, + stops the loop, and joins the background thread (with a 5 s + timeout). + +If you cannot use `with`, call them manually: + +```python +ha = SyncHAClient.from_url(url, token=token) +ha.connect() +try: + ... +finally: + ha.close() +``` + +**Always call `close()`.** Failing to do so leaves the loop thread +running for the lifetime of the process; the thread is a daemon so +the interpreter can still exit, but pending tasks may be abandoned +mid-flight. + +## Listeners on the sync wrapper + +You can still use `on_state_change` and the granular `on_*` +decorators. Handlers can be plain functions; they run on the +background loop thread, so any code they execute should be +thread-safe with the rest of your program. + +```python +light = ha.light("kitchen") + +@light.on_brightness_change +def on_brightness(old: int | None, new: int | None) -> None: + print(f"kitchen brightness {old} -> {new}") +``` + +Async handlers also work and are awaited on the loop thread. + +## Reconnect hooks + +`on_reconnect` and `on_disconnect` work the same as on the async +client: + +```python +@ha.on_disconnect +def disconnected() -> None: + print("ha gone") + +@ha.on_reconnect +def reconnected() -> None: + print("ha back; state has been re-primed") +``` + +See the [reconnect guide](reconnect.md) for what runs automatically +between those two events. + +## When *not* to use it + +- Inside an existing `asyncio` application — use `HAClient` directly. + Wrapping an async event loop inside another loop thread just adds + overhead and surprising deadlocks. +- In a long-running server. The blocking-call-from-one-thread model + serialises every operation across the loop thread, which becomes + the bottleneck at any real concurrency. Servers should use + `HAClient` from native async code. diff --git a/docs/index.md b/docs/index.md index 2d4105e..b10cd30 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,9 +18,21 @@ WebSocket support. - Real-time state-change listeners with attribute and transition filtering. - Synchronous blocking wrapper for scripts, REPL, and Jupyter. -- Explicit service-call routing: `prefer="ws" | "rest" | "auto"`. +- Explicit service-call routing (`prefer="ws" | "rest" | "auto"`) + available via `client.services.call(...)` for advanced use; the + normal path is the high-level domain methods. - Fully typed (PEP 561) with strict mypy enforcement. +## Next steps + +- New here? Start with the [Guides](guides/index.md) — they walk + through async lifecycle, listeners, reconnect handling, custom + domains, and per-domain workflows. +- Looking up a specific method or class? See the + [API Reference](reference/index.md). +- Curious how the pieces fit together? Read the + [Architecture](architecture.md) document. + ## Installation ```bash @@ -70,6 +82,9 @@ Use this extension point to add a domain that HaClient does not ship with. Built-in domains such as `fan`, `light`, and `cover` are already registered at import time and cannot be replaced. +Register *before* constructing the client — active domains are +snapshotted at `HAClient` construction time. + ```python from haclient import DomainSpec, Entity, register_domain @@ -87,6 +102,11 @@ Once registered, the domain is reachable through the same accessors as built-ins: ```python -sprinkler = ha.domain("sprinkler")["lawn"] -await sprinkler.start(600) +async with HAClient.from_url("http://localhost:8123", token="YOUR_TOKEN") as ha: + sprinkler = ha.domain("sprinkler")["lawn"] + await sprinkler.start(600) ``` + +See the [Custom domains and plugins guide](guides/custom-domains.md) +for collection-level operations, event routing, listener +decorators, and entry-point publishing. diff --git a/docs/reference/core/events.md b/docs/reference/core/events.md index 18cf9d8..859aa37 100644 --- a/docs/reference/core/events.md +++ b/docs/reference/core/events.md @@ -1,3 +1,9 @@ # Event Bus +User-facing pub/sub façade over Home Assistant's WebSocket event +stream. Subscriptions automatically survive reconnects — see +[Reconnect handling](../../guides/reconnect.md). For typical +per-entity reactions prefer the listener decorators described in +[State and value listeners](../../guides/listeners.md). + ::: haclient.core.events diff --git a/docs/reference/core/plugins.md b/docs/reference/core/plugins.md index 3b92b8f..a86d1f9 100644 --- a/docs/reference/core/plugins.md +++ b/docs/reference/core/plugins.md @@ -1,3 +1,9 @@ # Plugins (DomainSpec / DomainRegistry) +Declarative model for HaClient's typed domain accessors. Built-in +domains register on import; third-party packages can ship additional +domains via the `haclient.domains` entry point. See the +[Custom domains and plugins](../../guides/custom-domains.md) guide +for the end-to-end workflow. + ::: haclient.core.plugins diff --git a/docs/reference/core/services.md b/docs/reference/core/services.md index e4ed276..51fc345 100644 --- a/docs/reference/core/services.md +++ b/docs/reference/core/services.md @@ -1,3 +1,10 @@ # Service Caller +Routes raw HA service invocations between the WebSocket and REST +transports according to a `ServicePolicy` (`"ws"`, `"rest"`, or +`"auto"`). Most users should call services through the domain +entity methods (e.g. `ha.light("kitchen").set_brightness(...)`) and +treat `ServiceCaller.call` as an escape hatch — see +[Service routing (advanced)](../../guides/service-routing.md). + ::: haclient.core.services diff --git a/mkdocs.yml b/mkdocs.yml index 9414806..c45a61d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,23 @@ theme: nav: - Home: index.md - Architecture: architecture.md + - Guides: + - Overview: guides/index.md + - Async lifecycle: guides/lifecycle.md + - Sync wrapper: guides/sync-wrapper.md + - State and value listeners: guides/listeners.md + - Reconnect handling: guides/reconnect.md + - Custom domains and plugins: guides/custom-domains.md + - Service routing (advanced): guides/service-routing.md + - Domain workflows: + - Light: guides/domains/light.md + - Switch: guides/domains/switch.md + - Climate: guides/domains/climate.md + - Cover: guides/domains/cover.md + - Media Player: guides/domains/media_player.md + - Scene: guides/domains/scene.md + - Timer: guides/domains/timer.md + - Sensor: guides/domains/sensor.md - API Reference: - Overview: reference/index.md - HAClient (facade): reference/api.md