Skip to content

Commit fe45cb0

Browse files
authored
Merge pull request #101 from graphras-com/docs/narrative-guides
docs: add narrative guides for public workflows (closes #92)
2 parents a5c10de + e05b505 commit fe45cb0

20 files changed

Lines changed: 1776 additions & 3 deletions

docs/guides/custom-domains.md

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
# Custom domains and plugins
2+
3+
HaClient ships with typed accessors for the most common Home
4+
Assistant domains: `light`, `switch`, `climate`, `cover`, `fan`,
5+
`humidifier`, `lock`, `media_player`, `scene`, `sensor`,
6+
`binary_sensor`, `air_quality`, `event`, `timer`, `vacuum`, `valve`.
7+
8+
When you need something we do not ship, the same plugin model that
9+
built-ins use is available to you.
10+
11+
## In-process registration
12+
13+
The fastest path is to define an `Entity` subclass and register it
14+
before constructing your client:
15+
16+
```python
17+
from haclient import HAClient, DomainSpec, Entity, register_domain
18+
19+
class Sprinkler(Entity):
20+
domain = "sprinkler"
21+
22+
async def start(self, duration: int) -> None:
23+
await self._call_service("start", {"duration": duration})
24+
25+
async def stop(self) -> None:
26+
await self._call_service("stop")
27+
28+
register_domain(DomainSpec(name="sprinkler", entity_cls=Sprinkler))
29+
30+
async with HAClient.from_url(url, token=token) as ha:
31+
sprinkler = ha.domain("sprinkler")["lawn"]
32+
await sprinkler.start(600)
33+
```
34+
35+
A few important details:
36+
37+
- **Register before construction.** Active domains are snapshotted
38+
when `HAClient` is constructed. Registering a new spec on the
39+
shared registry *after* a client exists will not retroactively add
40+
the domain to that client. Register first, then construct.
41+
- **`Entity` subclasses must set `domain`** to the HA domain string
42+
(matching the `name` you give the spec). It is used by
43+
`_call_service` to route service invocations to the right HA
44+
domain.
45+
- **Use `_call_service`** for entity-scoped actions. It automatically
46+
injects `entity_id` and routes through the shared `ServiceCaller`.
47+
48+
The accessor for a custom domain is reached via
49+
`ha.domain("sprinkler")` or — once you have ensured the domain name
50+
does not collide with a built-in attribute — via attribute access
51+
`ha.sprinkler("lawn")`.
52+
53+
## Adding listener decorators
54+
55+
Custom domains can expose typed listener decorators in exactly the
56+
same way the built-ins do:
57+
58+
```python
59+
from typing import TypeVar
60+
from haclient import Entity
61+
62+
V = TypeVar("V") # ValueChangeHandler
63+
64+
class Sprinkler(Entity):
65+
domain = "sprinkler"
66+
67+
def on_start(self, func: V) -> V:
68+
"""Decorator: fire when state transitions to 'running'."""
69+
return self._register_state_transition_listener("running", func)
70+
71+
def on_remaining_change(self, func: V) -> V:
72+
"""Decorator: fire when the 'remaining' attribute changes."""
73+
return self._register_attr_listener("remaining", func)
74+
```
75+
76+
See the [listeners guide](listeners.md) for the three built-in
77+
categories you can wrap.
78+
79+
## Collection-level operations
80+
81+
If your domain has actions that are not tied to a single entity —
82+
analogous to `scene.apply(...)` or `timer.create(...)` — subclass
83+
`DomainAccessor` and attach it to the spec:
84+
85+
```python
86+
from typing import Any
87+
from haclient import DomainSpec, DomainAccessor, Entity, register_domain
88+
89+
class IrrigationAccessor(DomainAccessor["Sprinkler"]):
90+
async def run_program(self, program_id: str, *, zones: list[str]) -> None:
91+
await self.factory.services.call(
92+
"sprinkler",
93+
"run_program",
94+
{"program_id": program_id, "zones": zones},
95+
)
96+
97+
register_domain(
98+
DomainSpec(
99+
name="sprinkler",
100+
entity_cls=Sprinkler,
101+
accessor_cls=IrrigationAccessor,
102+
)
103+
)
104+
```
105+
106+
Use `self.factory.services` and `self.factory.state` from inside the
107+
accessor. These are the public hooks; do not reach into the private
108+
underscore-prefixed attributes.
109+
110+
## Routing custom HA events
111+
112+
If your domain emits HA events other than `state_changed` (the way
113+
`timer.finished` works for the built-in `timer` domain), declare them
114+
on the spec:
115+
116+
```python
117+
def on_sprinkler_event(entity: Entity, event_type: str, data: dict[str, Any]) -> None:
118+
print(f"{entity.entity_id} got {event_type}: {data}")
119+
120+
register_domain(
121+
DomainSpec(
122+
name="sprinkler",
123+
entity_cls=Sprinkler,
124+
event_subscriptions=("sprinkler.zone_finished",),
125+
on_event=on_sprinkler_event,
126+
)
127+
)
128+
```
129+
130+
The client subscribes to each listed event type when it starts and
131+
routes each event to the registered entity via the
132+
`on_event` callback. Events whose `data.entity_id` is unknown are
133+
silently dropped.
134+
135+
## Shipping a plugin via an entry point
136+
137+
If you maintain a separate Python package and want HaClient users to
138+
get your domain automatically, expose an entry point under
139+
`haclient.domains` in your package metadata:
140+
141+
```toml
142+
# pyproject.toml of your plugin package
143+
[project.entry-points."haclient.domains"]
144+
sprinkler = "my_haclient_sprinkler.plugin"
145+
```
146+
147+
The module referenced (`my_haclient_sprinkler.plugin`) should
148+
register the spec at import time:
149+
150+
```python
151+
# my_haclient_sprinkler/plugin.py
152+
from haclient import DomainSpec, Entity, register_domain
153+
154+
class Sprinkler(Entity):
155+
domain = "sprinkler"
156+
...
157+
158+
register_domain(DomainSpec(name="sprinkler", entity_cls=Sprinkler))
159+
```
160+
161+
`HAClient.from_url(..., load_plugins=True)` is the default and
162+
discovers your entry point. Each plugin is loaded inside a try /
163+
except so one broken plugin cannot prevent the rest from loading;
164+
failures are logged but do not raise.
165+
166+
To opt out of plugin discovery for a specific client (useful in
167+
tests):
168+
169+
```python
170+
ha = HAClient.from_url(url, token=token, load_plugins=False)
171+
```
172+
173+
## Restricting active domains
174+
175+
If you only need a subset of domains for a particular client, pass
176+
`domains=`:
177+
178+
```python
179+
ha = HAClient.from_url(url, token=token, domains=["light", "switch"])
180+
```
181+
182+
Unlisted domains are not exposed on the client even though they
183+
remain in the shared registry.
184+
185+
## Replacing a built-in domain
186+
187+
This is not supported. Re-registering an existing domain name with a
188+
different `entity_cls` raises `HAClientError`. Re-registering the
189+
same class is a no-op (so importing the same plugin twice is safe).
190+
191+
If you want to *extend* a built-in domain — for example, add a
192+
custom method on `Light` — subclass it in your own code and use it
193+
from your own helpers; do not try to monkey-patch the registry.

docs/guides/domains/climate.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Climate
2+
3+
The `climate` accessor returns `Climate` entities representing
4+
thermostats, A/C units, and HVAC systems.
5+
6+
## Reading current state
7+
8+
```python
9+
climate = ha.climate("living_room")
10+
11+
print(climate.hvac_mode) # "heat", "cool", "off", ...
12+
print(climate.current_temperature) # sensed temperature, or None
13+
print(climate.target_temperature) # current setpoint, or None
14+
print(climate.hvac_modes) # supported modes, e.g. ["off", "heat", "cool"]
15+
```
16+
17+
`hvac_mode` is the entity's `state` string — they are aliases. We
18+
expose it under the intent-specific name because that is what users
19+
actually mean.
20+
21+
## Setting the setpoint
22+
23+
```python
24+
await climate.set_temperature(temperature=21.5)
25+
```
26+
27+
The method accepts the underlying HA service's full parameter set —
28+
including `target_temp_high` / `target_temp_low` for ranged
29+
thermostats — via the same keyword arguments HA expects.
30+
31+
## Switching modes
32+
33+
```python
34+
await climate.set_hvac_mode("heat")
35+
await climate.set_hvac_mode("off")
36+
await climate.set_fan_mode("auto")
37+
```
38+
39+
Use values from `climate.hvac_modes` rather than guessing. Different
40+
devices support different mode strings.
41+
42+
## Reacting to changes
43+
44+
```python
45+
@climate.on_hvac_mode_change
46+
def mode(old: str | None, new: str | None) -> None:
47+
print(f"hvac {old} -> {new}")
48+
49+
@climate.on_temperature_change
50+
def measured(old: float | None, new: float | None) -> None:
51+
print(f"current temp now {new}")
52+
53+
@climate.on_target_temperature_change
54+
def setpoint(old: float | None, new: float | None) -> None:
55+
print(f"setpoint now {new}")
56+
```
57+
58+
`on_temperature_change` fires on the sensed temperature; use
59+
`on_target_temperature_change` for setpoint changes (e.g. someone
60+
moved the slider in the HA UI).
61+
62+
## Common patterns
63+
64+
### Bump the setpoint
65+
66+
```python
67+
current = climate.target_temperature or 20.0
68+
await climate.set_temperature(temperature=current + 0.5)
69+
```
70+
71+
### Switch to heat if cold enough
72+
73+
```python
74+
if (climate.current_temperature or 100) < 18 and climate.hvac_mode == "off":
75+
await climate.set_hvac_mode("heat")
76+
await climate.set_temperature(temperature=20)
77+
```
78+
79+
### Log every setpoint change
80+
81+
```python
82+
@climate.on_target_temperature_change
83+
async def audit(old, new):
84+
await db.insert("hvac_setpoint", entity=climate.entity_id, old=old, new=new)
85+
```

docs/guides/domains/cover.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Cover
2+
3+
The `cover` accessor returns `Cover` entities — garage doors,
4+
blinds, shades, awnings. The HA `cover` domain conflates "open /
5+
close" with "set position to N%"; HaClient exposes both clearly.
6+
7+
## Reading state
8+
9+
```python
10+
cover = ha.cover("garage")
11+
12+
print(cover.is_open) # state == "open"
13+
print(cover.is_closed) # state == "closed"
14+
print(cover.current_position) # 0-100 (closed-open), or None
15+
```
16+
17+
`state` is a string and may also be `"opening"`, `"closing"`, or
18+
`"unknown"`. `is_open` / `is_closed` only check the steady-state
19+
strings — neither is `True` mid-motion.
20+
21+
## Opening, closing, stopping
22+
23+
```python
24+
await cover.open()
25+
await cover.close()
26+
await cover.stop() # stop a motion in progress
27+
await cover.toggle() # open if closed, close otherwise
28+
```
29+
30+
`stop()` is a no-op for covers that do not support stopping.
31+
32+
## Setting a specific position
33+
34+
```python
35+
await cover.set_position(50) # half-open
36+
await cover.set_position(100) # fully open (equivalent to open())
37+
await cover.set_position(0) # fully closed (equivalent to close())
38+
```
39+
40+
`position` is `0..100`. Covers that do not support intermediate
41+
positions will round to the nearest supported value (typically `0`
42+
or `100`).
43+
44+
## Reacting to changes
45+
46+
```python
47+
@cover.on_open
48+
def opened(old: str | None, new: str | None) -> None:
49+
print("garage is now open")
50+
51+
@cover.on_close
52+
def closed(old, new): ...
53+
54+
@cover.on_position_change
55+
def position(old: int | None, new: int | None) -> None:
56+
print(f"garage position {old} -> {new}")
57+
```
58+
59+
`on_open` / `on_close` fire on the *transition into* the open or
60+
closed state — not on each tick of motion. Use `on_position_change`
61+
if you need every percentage update.
62+
63+
## Common patterns
64+
65+
### Open only if currently closed
66+
67+
```python
68+
if cover.is_closed:
69+
await cover.open()
70+
```
71+
72+
### Vent — open partially, then close again
73+
74+
```python
75+
await cover.set_position(20)
76+
await asyncio.sleep(300)
77+
await cover.close()
78+
```
79+
80+
### Sync two covers
81+
82+
```python
83+
target = ha.cover("blind_left").current_position or 100
84+
await ha.cover("blind_right").set_position(target)
85+
```

0 commit comments

Comments
 (0)