Skip to content

Commit 942044a

Browse files
authored
Merge pull request #85 from graphras-com/feature/typed-domain-accessors
refactor: introduce typed SceneAccessor and TimerAccessor
2 parents 631cfc5 + 108245d commit 942044a

5 files changed

Lines changed: 247 additions & 141 deletions

File tree

src/haclient/api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ def __init__(
128128
active = self._select_active_domains(domains)
129129
self._accessors: dict[str, DomainAccessor[Any]] = {}
130130
for spec in active:
131-
accessor: DomainAccessor[Any] = DomainAccessor(spec, self._factory)
131+
cls = spec.accessor_cls if spec.accessor_cls is not None else DomainAccessor
132+
accessor: DomainAccessor[Any] = cls(spec, self._factory)
132133
self._accessors[spec.accessor_name()] = accessor
133134
self._accessors[spec.name] = accessor
134135
for event_type in spec.event_subscriptions:

src/haclient/core/plugins.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
``ha.light`` or ``ha.scene``). It provides:
99
1010
* ``__call__(name)`` and ``__getitem__(name)`` for entity lookup.
11-
* Domain-level operations registered by the spec via ``operations``.
11+
* Domain-level operations registered by the spec via ``operations`` (legacy
12+
third-party path) **or** via typed subclass methods (preferred path).
13+
14+
Domains with collection-level operations should subclass `DomainAccessor`
15+
and register the subclass via ``DomainSpec.accessor_cls``. This keeps the
16+
public API statically typed without requiring ``# type: ignore`` workarounds
17+
or private ``_factory`` access from outside the accessor.
1218
1319
Third-party plugins can ship additional domains by exposing an entry
1420
point under the ``haclient.domains`` group; see
@@ -63,10 +69,14 @@ class DomainSpec(Generic[E]):
6369
on_event : callable or None
6470
Per-domain event handler (see `DomainEventHandler`).
6571
operations : dict
66-
Domain-level async operations registered on the
67-
`DomainAccessor`. Each value is an async callable; it will be
68-
bound to the accessor so the first positional argument *is* the
69-
accessor instance.
72+
Legacy dynamic operation dict kept for third-party plugin
73+
compatibility. Built-in domains with collection-level operations
74+
should prefer ``accessor_cls`` instead.
75+
accessor_cls : type[DomainAccessor] or None
76+
Optional typed `DomainAccessor` subclass to instantiate for this
77+
domain. When provided, the ``HAClient`` uses this class rather than
78+
the base `DomainAccessor`, exposing properly typed collection-level
79+
methods (e.g. ``SceneAccessor.create``).
7080
"""
7181

7282
name: str
@@ -75,6 +85,7 @@ class DomainSpec(Generic[E]):
7585
event_subscriptions: tuple[str, ...] = ()
7686
on_event: DomainEventHandler | None = None
7787
operations: dict[str, Callable[..., Any]] = field(default_factory=dict)
88+
accessor_cls: type[DomainAccessor[Any]] | None = None
7889

7990
def accessor_name(self) -> str:
8091
"""Return the accessor attribute name (defaults to ``name``)."""
@@ -87,14 +98,14 @@ class DomainAccessor(Generic[E]):
8798
Returned by ``HAClient.<accessor>``. Exposes:
8899
89100
* Lookup by short name: ``ha.light("kitchen")`` or ``ha.light["kitchen"]``.
90-
* Domain-level operations registered on the spec, bound to this accessor:
91-
``await ha.scene.create("romantic", ...)``.
101+
* Domain-level operations either via typed subclass methods (preferred) or
102+
via legacy dynamic binding of ``spec.operations`` entries.
92103
93104
Parameters
94105
----------
95106
spec : DomainSpec
96107
The spec describing this domain.
97-
factory : EntityFactory
108+
factory : EntityFactoryProtocol
98109
Factory used to create entity instances on demand.
99110
"""
100111

@@ -103,13 +114,23 @@ def __init__(self, spec: DomainSpec[E], factory: EntityFactoryProtocol) -> None:
103114
self._factory = factory
104115
for op_name, op in spec.operations.items():
105116
# Bind each operation as an attribute on the instance.
117+
# This path is kept for backward-compatible third-party plugins.
106118
setattr(self, op_name, self._bind(op))
107119

108120
@property
109121
def spec(self) -> DomainSpec[E]:
110122
"""Return the underlying `DomainSpec`."""
111123
return self._spec
112124

125+
@property
126+
def factory(self) -> EntityFactoryProtocol:
127+
"""Return the `EntityFactoryProtocol` used to create entities.
128+
129+
Subclasses use this to access ``factory.services`` and
130+
``factory.state`` without reaching into private internals.
131+
"""
132+
return self._factory
133+
113134
def _bind(self, op: Callable[..., Any]) -> Callable[..., Any]:
114135
"""Bind a domain operation to this accessor.
115136

src/haclient/domains/scene.py

Lines changed: 77 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,24 @@
66
Domain-level operations
77
-----------------------
88
Beyond per-entity actions, the scene domain exposes two collection-level
9-
operations on the `DomainAccessor`:
9+
operations on the `SceneAccessor` (returned by ``ha.scene``):
1010
11-
* ``create(scene_id, entities, *, snapshot_entities=None) -> Scene`` —
12-
create (or update) a runtime scene helper.
13-
* ``apply(entities, *, transition=None) -> None`` — apply a state
14-
combination without persisting it.
11+
* ``await ha.scene.create(scene_id, entities, *, snapshot_entities=None)``
12+
create (or update) a runtime scene helper, returning a `Scene`.
13+
* ``await ha.scene.apply(entities, *, transition=None)``
14+
— apply a state combination without persisting it.
1515
16-
These are invoked as ``await ha.scene.create(...)`` and
17-
``await ha.scene.apply(...)``. Per-entity access still works through the
18-
usual ``ha.scene("name")`` / ``ha.scene["name"]`` syntax.
16+
Per-entity access still works through the usual
17+
``ha.scene("name")`` / ``ha.scene["name"]`` syntax.
1918
"""
2019

2120
from __future__ import annotations
2221

23-
from typing import TYPE_CHECKING, Any
22+
from typing import Any
2423

2524
from haclient.core.plugins import DomainAccessor, DomainSpec, register_domain
2625
from haclient.entity.base import Entity, ValueChangeHandler
2726

28-
if TYPE_CHECKING:
29-
from haclient.core.factory import EntityFactory
30-
3127

3228
class Scene(Entity):
3329
"""A Home Assistant scene entity.
@@ -108,74 +104,82 @@ def on_activate(self, func: ValueChangeHandler) -> ValueChangeHandler:
108104
return self._register_state_value_listener(func)
109105

110106

111-
# -- Domain-level operations --------------------------------------------
112-
113-
114-
async def _create(
115-
accessor: DomainAccessor[Scene],
116-
scene_id: str,
117-
entities: dict[str, dict[str, Any]],
118-
*,
119-
snapshot_entities: list[str] | None = None,
120-
) -> Scene:
121-
"""Create a runtime scene and return the corresponding `Scene`.
122-
123-
Parameters
124-
----------
125-
accessor : DomainAccessor
126-
The scene accessor (provided automatically by the binding).
127-
scene_id : str
128-
Object-id for the new scene (e.g. ``"romantic"`` →
129-
``scene.romantic``).
130-
entities : dict
131-
Mapping of entity ids to state/attribute dicts.
132-
snapshot_entities : list of str or None, optional
133-
Entity ids whose current state should be captured.
134-
135-
Returns
136-
-------
137-
Scene
138-
The newly created scene.
139-
"""
140-
factory: EntityFactory = accessor._factory # type: ignore[assignment]
141-
services = factory.services
142-
payload: dict[str, Any] = {"scene_id": scene_id, "entities": entities}
143-
if snapshot_entities is not None:
144-
payload["snapshot_entities"] = snapshot_entities
145-
await services.call("scene", "create", payload)
146-
return accessor[scene_id]
147-
148-
149-
async def _apply(
150-
accessor: DomainAccessor[Scene],
151-
entities: dict[str, dict[str, Any]],
152-
*,
153-
transition: float | None = None,
154-
) -> None:
155-
"""Apply a scene-like state combination without persisting it.
156-
157-
Parameters
158-
----------
159-
accessor : DomainAccessor
160-
The scene accessor.
161-
entities : dict
162-
Mapping of entity ids to desired state/attribute dicts.
163-
transition : float or None, optional
164-
Transition seconds for entities that support it.
107+
# -- Typed domain accessor ----------------------------------------------
108+
109+
110+
class SceneAccessor(DomainAccessor[Scene]):
111+
"""Typed domain accessor for the ``scene`` domain.
112+
113+
Returned by ``ha.scene``. Provides statically-typed collection-level
114+
operations in addition to the standard entity lookup methods inherited
115+
from `DomainAccessor`.
165116
"""
166-
factory: EntityFactory = accessor._factory # type: ignore[assignment]
167-
services = factory.services
168-
payload: dict[str, Any] = {"entities": entities}
169-
if transition is not None:
170-
payload["transition"] = transition
171-
await services.call("scene", "apply", payload)
117+
118+
async def create(
119+
self,
120+
scene_id: str,
121+
entities: dict[str, dict[str, Any]],
122+
*,
123+
snapshot_entities: list[str] | None = None,
124+
) -> Scene:
125+
"""Create (or update) a runtime scene helper.
126+
127+
Parameters
128+
----------
129+
scene_id : str
130+
Object-id for the new scene (e.g. ``"romantic"`` →
131+
``scene.romantic``).
132+
entities : dict
133+
Mapping of entity ids to target state/attribute dicts.
134+
snapshot_entities : list of str or None, optional
135+
Entity ids whose current state should be captured instead of
136+
providing an explicit state dict.
137+
138+
Returns
139+
-------
140+
Scene
141+
The newly created (or updated) scene entity.
142+
"""
143+
from haclient.core.factory import EntityFactory
144+
145+
factory = self.factory
146+
assert isinstance(factory, EntityFactory)
147+
payload: dict[str, Any] = {"scene_id": scene_id, "entities": entities}
148+
if snapshot_entities is not None:
149+
payload["snapshot_entities"] = snapshot_entities
150+
await factory.services.call("scene", "create", payload)
151+
return self[scene_id]
152+
153+
async def apply(
154+
self,
155+
entities: dict[str, dict[str, Any]],
156+
*,
157+
transition: float | None = None,
158+
) -> None:
159+
"""Apply a scene-like state combination without persisting it.
160+
161+
Parameters
162+
----------
163+
entities : dict
164+
Mapping of entity ids to desired state/attribute dicts.
165+
transition : float or None, optional
166+
Transition seconds for entities that support it.
167+
"""
168+
from haclient.core.factory import EntityFactory
169+
170+
factory = self.factory
171+
assert isinstance(factory, EntityFactory)
172+
payload: dict[str, Any] = {"entities": entities}
173+
if transition is not None:
174+
payload["transition"] = transition
175+
await factory.services.call("scene", "apply", payload)
172176

173177

174178
SPEC: DomainSpec[Scene] = register_domain(
175179
DomainSpec(
176180
name="scene",
177181
entity_cls=Scene,
178-
operations={"create": _create, "apply": _apply},
182+
accessor_cls=SceneAccessor,
179183
)
180184
)
181185
"""The `DomainSpec` registered with the shared `DomainRegistry`."""

0 commit comments

Comments
 (0)