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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions docs/guides/custom-domains.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 85 additions & 0 deletions docs/guides/domains/climate.md
Original file line number Diff line number Diff line change
@@ -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)
```
85 changes: 85 additions & 0 deletions docs/guides/domains/cover.md
Original file line number Diff line number Diff line change
@@ -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)
```
Loading
Loading