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
29 changes: 20 additions & 9 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ async with HAClient.from_url("http://localhost:8123", token="YOUR_TOKEN") as ha:
await light.set_brightness(200)

# Generic accessor — works for any registered domain.
fan = ha.domain("fan")["ceiling"]
# await fan.set_speed(75)
fan = ha.fan("ceiling")
await fan.set_percentage(75)

# Domain-level operations.
await ha.scene.apply({"light.ceiling": {"state": "on", "brightness": 120}})
Expand All @@ -64,16 +64,27 @@ with SyncHAClient.from_url("http://localhost:8123", token="YOUR_TOKEN") as ha:

### Adding a custom domain

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.

```python
from haclient import register_domain, DomainSpec, Entity
from haclient import DomainSpec, Entity, register_domain

class Fan(Entity):
domain = "fan"
class Sprinkler(Entity):
domain = "sprinkler"

async def set_speed(self, pct: int) -> None:
await self._call_service("set_percentage", {"percentage": pct})
async def start(self, duration: int) -> None:
# Extension implementation: call the underlying HA service directly.
await self._call_service("start", {"duration": duration})

register_domain(DomainSpec(name="fan", entity_cls=Fan))
register_domain(DomainSpec(name="sprinkler", entity_cls=Sprinkler))
```

Built-in or third-party — both routes are equivalent.
Once registered, the domain is reachable through the same accessors as
built-ins:

```python
sprinkler = ha.domain("sprinkler")["lawn"]
await sprinkler.start(600)
```
45 changes: 45 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,48 @@ async def test_accessor_cls_none_falls_back_to_base() -> None:
assert type(accessor) is DomainAccessor
finally:
await ha.close()


def test_docs_custom_domain_example_runs() -> None:
"""Regression for #88: the docs custom-domain example must execute cleanly.

The README/quick-start once registered a duplicate ``fan`` domain, which
raised ``HAClientError`` the moment a user copied it. This test extracts
the documented example and re-runs it against an isolated registry so any
future regression — duplicate name, removed helper, renamed import — is
caught immediately.
"""
from pathlib import Path

docs_path = Path(__file__).resolve().parent.parent / "docs" / "index.md"
text = docs_path.read_text(encoding="utf-8")

marker = "### Adding a custom domain"
assert marker in text, "Quick-start section missing from docs/index.md"
section = text.split(marker, 1)[1]
# Grab the first fenced python block within the section.
fence_open = section.index("```python") + len("```python")
fence_close = section.index("```", fence_open)
snippet = section[fence_open:fence_close].strip()

# The published example uses the shared registry via ``register_domain``;
# rebind that helper to an isolated registry so the test never mutates
# process-wide state. Strip the import line so our injected bindings win.
snippet_lines = [
line for line in snippet.splitlines() if not line.startswith("from haclient import")
]
snippet = "\n".join(snippet_lines)

isolated = DomainRegistry()

def _register(spec: DomainSpec[Any]) -> None:
isolated.register(spec)

namespace: dict[str, Any] = {
"DomainSpec": DomainSpec,
"Entity": Entity,
"register_domain": _register,
}
exec(compile(snippet, str(docs_path), "exec"), namespace)

assert "sprinkler" in isolated, "Docs example must register the 'sprinkler' domain"
Loading