diff --git a/docs/index.md b/docs/index.md index 408d839..cd583c7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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}}) @@ -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) +``` diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 9009c50..6ec2a0c 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -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"