homeassistant-repl 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- homeassistant_repl/__init__.py +32 -0
- homeassistant_repl/__main__.py +3 -0
- homeassistant_repl/api_objtree.py +499 -0
- homeassistant_repl/apirepl.py +71 -0
- homeassistant_repl/cli.py +178 -0
- homeassistant_repl/client.py +133 -0
- homeassistant_repl/local_session.py +117 -0
- homeassistant_repl/repl.py +78 -0
- homeassistant_repl-0.2.0.dist-info/METADATA +261 -0
- homeassistant_repl-0.2.0.dist-info/RECORD +12 -0
- homeassistant_repl-0.2.0.dist-info/WHEEL +4 -0
- homeassistant_repl-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Command line client for Home Assistant REPL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .api_objtree import ApiObjTree, Cache
|
|
6
|
+
from .client import Client, HaReplError, resolve_token, resolve_url
|
|
7
|
+
|
|
8
|
+
__all__ = ["ApiObjTree", "Cache", "Client", "HaReplError", "connect"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def connect(
|
|
12
|
+
url: str | None = None, token: str | None = None, *, ttl: float = 30.0
|
|
13
|
+
) -> ApiObjTree:
|
|
14
|
+
"""Connect to Home Assistant's websocket API and return a ready-to-use
|
|
15
|
+
`obj` tree - the one-liner version of API client mode (see
|
|
16
|
+
apirepl.py/api_objtree.py for the pieces this wires together), for
|
|
17
|
+
scripts and plain Python sessions rather than the `ha-repl` REPL.
|
|
18
|
+
|
|
19
|
+
`url`/`token` default to $HASS_SERVER/$HASS_TOKEN (and, inside a Home
|
|
20
|
+
Assistant add-on, the supervisor) the same way the CLI does - see
|
|
21
|
+
resolve_url()/resolve_token(). `ttl` is how long the snapshot is used
|
|
22
|
+
before it's considered stale; this helper doesn't auto-refresh, so call
|
|
23
|
+
`await obj.cache.refresh()` yourself when you want a newer one.
|
|
24
|
+
|
|
25
|
+
The websocket stays open for the life of the process - call
|
|
26
|
+
`await obj.cache.client.close()` when you're done with it, if that
|
|
27
|
+
matters for your script.
|
|
28
|
+
"""
|
|
29
|
+
client = await Client(resolve_url(url), resolve_token(token)).open()
|
|
30
|
+
cache = Cache(client, ttl)
|
|
31
|
+
await cache.refresh()
|
|
32
|
+
return ApiObjTree(cache)
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""ApiObjTree - API client mode's read-only `obj[...]`, built entirely from
|
|
2
|
+
Home Assistant's standard websocket API (get_states, config/*_registry/list)
|
|
3
|
+
- no ha_repl_server component needed, works against any instance an admin
|
|
4
|
+
token can reach. Bound to the same name, `obj`, as the live tree (see
|
|
5
|
+
apirepl.py) so a snippet that only touches `obj` runs unchanged in either
|
|
6
|
+
mode. Entities only have what that API exposes: state + attributes from
|
|
7
|
+
get_states, and whatever the entity registry's own partial dict exposes (no
|
|
8
|
+
live component instance, so no calling methods on it, no fields an
|
|
9
|
+
integration keeps off the registry/state).
|
|
10
|
+
|
|
11
|
+
Mirrors custom_components/ha_repl_server/objtree.py's shape - __getitem__,
|
|
12
|
+
.keys()/.values()/.items(), find(), show() all behave the same way modulo the
|
|
13
|
+
smaller attribute set - but lives in this package rather than that one (a
|
|
14
|
+
HACS-deployed component with its own packaging boundary), so the handful of
|
|
15
|
+
small, stable, HA-import-free pieces it needs (the ordered-view mixin,
|
|
16
|
+
parse_path, the None/empty-collection cleanup) are duplicated here rather than
|
|
17
|
+
imported across that boundary. Revisit if API client mode sticks and a shared
|
|
18
|
+
package becomes worth the packaging work.
|
|
19
|
+
|
|
20
|
+
Unlike the live tree, this one is explicitly a *snapshot*: fetching is async
|
|
21
|
+
(it's a websocket call) but Mapping's `__getitem__`/`__iter__`/`__len__` are
|
|
22
|
+
not, so refreshing can't happen lazily on access without risking asyncio
|
|
23
|
+
reentrancy (this is normally used from inside an already-running event loop).
|
|
24
|
+
Instead the owning REPL loop decides when to call `await cache.refresh()`
|
|
25
|
+
(e.g. between prompts, when `cache.is_stale()`) - see apirepl.py. `reset_cache()`
|
|
26
|
+
just marks the cache stale; it does not fetch.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import time
|
|
32
|
+
from collections.abc import (
|
|
33
|
+
ItemsView,
|
|
34
|
+
Iterable,
|
|
35
|
+
Iterator,
|
|
36
|
+
KeysView,
|
|
37
|
+
Mapping,
|
|
38
|
+
Sequence,
|
|
39
|
+
ValuesView,
|
|
40
|
+
)
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
from typing import Any, NamedTuple, Protocol
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _ApiClient(Protocol):
|
|
47
|
+
"""What Cache actually needs from a client - just enough to let tests use
|
|
48
|
+
a lightweight fake instead of a real websocket Client."""
|
|
49
|
+
|
|
50
|
+
async def call(self, type_: str, **payload: Any) -> Any: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_path(path: str) -> tuple[str, ...]:
|
|
54
|
+
"""Split "/integration[/domain[/object_id]]" into 1-3 parts. Duplicated
|
|
55
|
+
from custom_components/ha_repl_server/paths.py - see the module
|
|
56
|
+
docstring for why."""
|
|
57
|
+
parts = tuple(p for p in path.split("/") if p)
|
|
58
|
+
if not parts or len(parts) > 3:
|
|
59
|
+
raise ValueError(f"{path!r} - expected /integration/domain/object_id")
|
|
60
|
+
return parts
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _as_set(value: str | list[str] | None) -> set[str]:
|
|
64
|
+
if value is None:
|
|
65
|
+
return set()
|
|
66
|
+
return {value} if isinstance(value, str) else set(value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _require_str(value: Any, what: str = "path") -> str:
|
|
70
|
+
"""A clear TypeError at the boundary beats a confusing one from deep
|
|
71
|
+
inside parse_path - duplicated from objtree.py's identical helper, see
|
|
72
|
+
that module for the rationale."""
|
|
73
|
+
if not isinstance(value, str):
|
|
74
|
+
raise TypeError(f"{what} must be a str, not {type(value).__name__}")
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _OrderedView(Iterable[Any]):
|
|
79
|
+
"""Positional access/slicing on top of a MappingView's existing order -
|
|
80
|
+
duplicated from objtree.py's identical mixin, see that module for the
|
|
81
|
+
rationale."""
|
|
82
|
+
|
|
83
|
+
def __getitem__(self, index: int | slice) -> Any:
|
|
84
|
+
return tuple(self)[index]
|
|
85
|
+
|
|
86
|
+
def __reversed__(self) -> Iterator[Any]:
|
|
87
|
+
return reversed(tuple(self))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class OrderedKeysView(_OrderedView, KeysView, Sequence):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class OrderedValuesView(_OrderedView, ValuesView, Sequence):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class OrderedItemsView(_OrderedView, ItemsView, Sequence): # type: ignore[misc] # ty: ignore[invalid-method-override]
|
|
99
|
+
# Sequence.__contains__(value) vs ItemsView.__contains__(item: tuple) is a
|
|
100
|
+
# real static Liskov mismatch, but fine at runtime - both just delegate to
|
|
101
|
+
# tuple(self).__contains__ via _OrderedView/Iterable, deliberately getting
|
|
102
|
+
# both Set and Sequence behavior at once (see the module docstring).
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class ApiEntity:
|
|
108
|
+
"""One entity's worth of API client mode data - only what get_states and
|
|
109
|
+
the entity registry's partial dict expose, not the live component instance.
|
|
110
|
+
|
|
111
|
+
Named to match the real Entity's own properties where there's a direct
|
|
112
|
+
equivalent - state, state_attributes, name - per
|
|
113
|
+
https://developers.home-assistant.io/docs/core/entity/. `name` comes from
|
|
114
|
+
get_states' `friendly_name` attribute: that's already HA's own fully
|
|
115
|
+
resolved result of computing Entity.name (registry override, device name,
|
|
116
|
+
has_entity_name, etc.), published into the state machine for exactly this
|
|
117
|
+
kind of external consumer - cheaper and more faithful than recomputing it
|
|
118
|
+
from the pieces ourselves. `state_attributes` is everything else in that
|
|
119
|
+
attributes dict; broader than the live property of the same name (which
|
|
120
|
+
excludes things like unit_of_measurement that come from separate Entity
|
|
121
|
+
properties), since the state machine doesn't preserve that distinction.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
entity_id: str
|
|
125
|
+
platform: str
|
|
126
|
+
domain: str
|
|
127
|
+
object_id: str
|
|
128
|
+
state: str | None
|
|
129
|
+
name: str | None
|
|
130
|
+
state_attributes: dict[str, Any]
|
|
131
|
+
area_id: str | None
|
|
132
|
+
labels: frozenset[str]
|
|
133
|
+
registry: dict[str, Any]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _Found(NamedTuple):
|
|
137
|
+
"""Internal only - never returned from find()/find_paths()/find_names(),
|
|
138
|
+
just the shared (path, entity) pair each of them projects from
|
|
139
|
+
(.entity, .path, and .entity_id respectively) so the filtering logic in
|
|
140
|
+
_find() lives in exactly one place. Any attribute other than path/entity
|
|
141
|
+
falls through to `entity`, so e.g. found.entity_id works without
|
|
142
|
+
found.entity.entity_id."""
|
|
143
|
+
|
|
144
|
+
path: str
|
|
145
|
+
entity: ApiEntity
|
|
146
|
+
|
|
147
|
+
def __getattr__(self, name: str) -> Any:
|
|
148
|
+
return getattr(self.entity, name)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# The two registry JSON fields that are Unix-timestamp floats rather than the
|
|
152
|
+
# ISO datetime strings get_states already returns - see show()/_clean().
|
|
153
|
+
_TIMESTAMP_FIELDS = frozenset({"created_at", "modified_at"})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Cache:
|
|
157
|
+
"""Fetches and holds one snapshot of states + registries. Refresh is
|
|
158
|
+
driven by the REPL loop between commands - see the module docstring."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, client: _ApiClient, ttl: float) -> None:
|
|
161
|
+
self.client = client
|
|
162
|
+
self.ttl = ttl
|
|
163
|
+
self._fetched_at: float | None = None
|
|
164
|
+
self.entities: dict[str, ApiEntity] = {}
|
|
165
|
+
self.areas: dict[str, dict[str, Any]] = {}
|
|
166
|
+
self.labels: dict[str, dict[str, Any]] = {}
|
|
167
|
+
|
|
168
|
+
def is_stale(self) -> bool:
|
|
169
|
+
return (
|
|
170
|
+
self._fetched_at is None or (time.monotonic() - self._fetched_at) > self.ttl
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def reset(self) -> None:
|
|
174
|
+
"""Mark the cache stale. Doesn't fetch - see the module docstring."""
|
|
175
|
+
self._fetched_at = None
|
|
176
|
+
|
|
177
|
+
async def refresh(self) -> None:
|
|
178
|
+
states = {s["entity_id"]: s for s in await self.client.call("get_states")}
|
|
179
|
+
registry_entries = await self.client.call("config/entity_registry/list")
|
|
180
|
+
devices = {
|
|
181
|
+
d["id"]: d for d in await self.client.call("config/device_registry/list")
|
|
182
|
+
}
|
|
183
|
+
self.areas = {
|
|
184
|
+
a["area_id"]: a for a in await self.client.call("config/area_registry/list")
|
|
185
|
+
}
|
|
186
|
+
self.labels = {
|
|
187
|
+
l["label_id"]: l
|
|
188
|
+
for l in await self.client.call("config/label_registry/list")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
entities: dict[str, ApiEntity] = {}
|
|
192
|
+
for entry in registry_entries:
|
|
193
|
+
entity_id = entry["entity_id"]
|
|
194
|
+
domain, object_id = entity_id.split(".", 1)
|
|
195
|
+
state = states.get(entity_id, {})
|
|
196
|
+
area_id = entry.get("area_id")
|
|
197
|
+
if area_id is None and entry.get("device_id"):
|
|
198
|
+
device = devices.get(entry["device_id"])
|
|
199
|
+
if device is not None:
|
|
200
|
+
area_id = device.get("area_id")
|
|
201
|
+
# friendly_name is already HA's own fully-resolved Entity.name,
|
|
202
|
+
# published for exactly this kind of external consumer - see
|
|
203
|
+
# ApiEntity's docstring. Copy the dict before popping: it's
|
|
204
|
+
# `states[entity_id]`'s own attributes dict, not ours to mutate.
|
|
205
|
+
attributes = dict(state.get("attributes", {}))
|
|
206
|
+
name = attributes.pop("friendly_name", None)
|
|
207
|
+
entities[entity_id] = ApiEntity(
|
|
208
|
+
entity_id=entity_id,
|
|
209
|
+
platform=entry["platform"],
|
|
210
|
+
domain=domain,
|
|
211
|
+
object_id=object_id,
|
|
212
|
+
state=state.get("state"),
|
|
213
|
+
name=name,
|
|
214
|
+
state_attributes=attributes,
|
|
215
|
+
area_id=area_id,
|
|
216
|
+
labels=frozenset(entry.get("labels", ())),
|
|
217
|
+
registry=entry,
|
|
218
|
+
)
|
|
219
|
+
self.entities = entities
|
|
220
|
+
self._fetched_at = time.monotonic()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _resolve_from_cache(
|
|
224
|
+
values: str | list[str] | None,
|
|
225
|
+
by_id: dict[str, dict[str, Any]],
|
|
226
|
+
kind: str,
|
|
227
|
+
id_key: str,
|
|
228
|
+
) -> set[str]:
|
|
229
|
+
"""Resolve each of `values` (an id or a display name) to its canonical id.
|
|
230
|
+
|
|
231
|
+
Raises KeyError on anything that matches neither - a typo'd area/label
|
|
232
|
+
name should fail loudly, not just quietly match nothing in find().
|
|
233
|
+
"""
|
|
234
|
+
ids: set[str] = set()
|
|
235
|
+
for value in _as_set(values):
|
|
236
|
+
if value in by_id:
|
|
237
|
+
ids.add(value)
|
|
238
|
+
continue
|
|
239
|
+
match = next(
|
|
240
|
+
(
|
|
241
|
+
k
|
|
242
|
+
for k, v in by_id.items()
|
|
243
|
+
if v.get("name", "").casefold() == value.casefold()
|
|
244
|
+
),
|
|
245
|
+
None,
|
|
246
|
+
)
|
|
247
|
+
if match is None:
|
|
248
|
+
raise KeyError(f"no such {kind}: {value!r}")
|
|
249
|
+
ids.add(match)
|
|
250
|
+
return ids
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _normalize(value: Any) -> Any:
|
|
254
|
+
if isinstance(value, dict):
|
|
255
|
+
return _clean(value)
|
|
256
|
+
return value
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _clean(data: dict[str, Any]) -> dict[str, Any]:
|
|
260
|
+
"""Recursively drop None/empty-collection values and render the known
|
|
261
|
+
timestamp fields as local ISO 8601 strings.
|
|
262
|
+
|
|
263
|
+
Empty *lists* are dropped here too, not just empty sets: labels/aliases
|
|
264
|
+
etc. arrive over the wire as JSON lists (JSON has no set type), so a list
|
|
265
|
+
is this mode's wire-equivalent of the live tree's empty-set fields - and
|
|
266
|
+
keeping both modes behaving the same way is the point.
|
|
267
|
+
"""
|
|
268
|
+
cleaned: dict[str, Any] = {}
|
|
269
|
+
for key, value in data.items():
|
|
270
|
+
if key in _TIMESTAMP_FIELDS and isinstance(value, (int, float)):
|
|
271
|
+
value = datetime.fromtimestamp(value).astimezone().isoformat()
|
|
272
|
+
else:
|
|
273
|
+
value = _normalize(value)
|
|
274
|
+
if value is None or value == set() or value == []:
|
|
275
|
+
continue
|
|
276
|
+
cleaned[key] = value
|
|
277
|
+
return cleaned
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True)
|
|
281
|
+
class ApiObjTree(Mapping[str, "ApiEntity | ApiObjTree"]):
|
|
282
|
+
"""API client mode's view of the object tree - see the module docstring."""
|
|
283
|
+
|
|
284
|
+
cache: Cache
|
|
285
|
+
integration: str | None = None
|
|
286
|
+
domain: str | None = None
|
|
287
|
+
|
|
288
|
+
def reset_cache(self) -> None:
|
|
289
|
+
self.cache.reset()
|
|
290
|
+
|
|
291
|
+
def __getitem__(self, key: str) -> ApiEntity | ApiObjTree:
|
|
292
|
+
_require_str(key, "key")
|
|
293
|
+
if "/" not in key and self.integration is None:
|
|
294
|
+
entity = self.cache.entities.get(key)
|
|
295
|
+
if entity is None:
|
|
296
|
+
raise KeyError(key)
|
|
297
|
+
return entity
|
|
298
|
+
try:
|
|
299
|
+
parts = parse_path(key)
|
|
300
|
+
except ValueError as err:
|
|
301
|
+
raise KeyError(str(err)) from None
|
|
302
|
+
scope = tuple(p for p in (self.integration, self.domain) if p is not None)
|
|
303
|
+
full = scope + parts
|
|
304
|
+
if len(full) > 3:
|
|
305
|
+
raise KeyError(key)
|
|
306
|
+
if len(full) < 3:
|
|
307
|
+
subtree = ApiObjTree(self.cache, *full)
|
|
308
|
+
if not subtree:
|
|
309
|
+
raise KeyError(key)
|
|
310
|
+
return subtree
|
|
311
|
+
integration, domain, object_id = full
|
|
312
|
+
entity = self.cache.entities.get(f"{domain}.{object_id}")
|
|
313
|
+
if entity is None or entity.platform != integration:
|
|
314
|
+
raise KeyError(key)
|
|
315
|
+
return entity
|
|
316
|
+
|
|
317
|
+
def _entries(self, prefix: tuple[str, ...]) -> Iterator[ApiEntity]:
|
|
318
|
+
for entity in self.cache.entities.values():
|
|
319
|
+
if len(prefix) >= 1 and entity.platform != prefix[0]:
|
|
320
|
+
continue
|
|
321
|
+
if len(prefix) >= 2 and entity.domain != prefix[1]:
|
|
322
|
+
continue
|
|
323
|
+
if len(prefix) >= 3 and entity.object_id != prefix[2]:
|
|
324
|
+
continue
|
|
325
|
+
yield entity
|
|
326
|
+
|
|
327
|
+
def __iter__(self) -> Iterator[str]:
|
|
328
|
+
scope = tuple(p for p in (self.integration, self.domain) if p is not None)
|
|
329
|
+
children = {
|
|
330
|
+
(entity.platform, entity.domain, entity.object_id)[len(scope)]
|
|
331
|
+
for entity in self._entries(scope)
|
|
332
|
+
}
|
|
333
|
+
for child in sorted(children):
|
|
334
|
+
yield "/" + child
|
|
335
|
+
|
|
336
|
+
def __len__(self) -> int:
|
|
337
|
+
return sum(1 for _ in self)
|
|
338
|
+
|
|
339
|
+
def keys(self) -> OrderedKeysView:
|
|
340
|
+
return OrderedKeysView(self)
|
|
341
|
+
|
|
342
|
+
def values(self) -> OrderedValuesView:
|
|
343
|
+
return OrderedValuesView(self)
|
|
344
|
+
|
|
345
|
+
def items(self) -> OrderedItemsView:
|
|
346
|
+
return OrderedItemsView(self)
|
|
347
|
+
|
|
348
|
+
def _find(
|
|
349
|
+
self,
|
|
350
|
+
path: str,
|
|
351
|
+
*,
|
|
352
|
+
platform: str | list[str] | None,
|
|
353
|
+
domain: str | list[str] | None,
|
|
354
|
+
area: str | list[str] | None,
|
|
355
|
+
label: str | list[str] | None,
|
|
356
|
+
) -> Iterator[_Found]:
|
|
357
|
+
"""The real search, shared by find()/find_paths()/find_names() - each
|
|
358
|
+
just projects a different field from the (path, entity) pairs this
|
|
359
|
+
yields. `domain` matches the HA domain (e.g. "light") across
|
|
360
|
+
integrations, the same way `platform` does for the owning
|
|
361
|
+
integration; `area`/`label` each take an id or a display name. Each
|
|
362
|
+
of the four ORs within itself when given a list, and they AND
|
|
363
|
+
together. No order guarantee; wrap in sorted(...) if you want one.
|
|
364
|
+
"""
|
|
365
|
+
_require_str(path)
|
|
366
|
+
scope = tuple(p for p in (self.integration, self.domain) if p is not None)
|
|
367
|
+
try:
|
|
368
|
+
prefix = scope if path in ("", "/") else scope + parse_path(path)
|
|
369
|
+
except ValueError as err:
|
|
370
|
+
raise KeyError(str(err)) from None
|
|
371
|
+
if len(prefix) > 3:
|
|
372
|
+
raise KeyError(path)
|
|
373
|
+
|
|
374
|
+
platforms = _as_set(platform)
|
|
375
|
+
domains = _as_set(domain)
|
|
376
|
+
area_ids = _resolve_from_cache(area, self.cache.areas, "area", "area_id")
|
|
377
|
+
label_ids = _resolve_from_cache(label, self.cache.labels, "label", "label_id")
|
|
378
|
+
|
|
379
|
+
def _matches() -> Iterator[_Found]:
|
|
380
|
+
for entity in self._entries(prefix):
|
|
381
|
+
if platforms and entity.platform not in platforms:
|
|
382
|
+
continue
|
|
383
|
+
if domains and entity.domain not in domains:
|
|
384
|
+
continue
|
|
385
|
+
if area_ids and entity.area_id not in area_ids:
|
|
386
|
+
continue
|
|
387
|
+
if label_ids and not (entity.labels & label_ids):
|
|
388
|
+
continue
|
|
389
|
+
full_path = "/" + "/".join(
|
|
390
|
+
(entity.platform, entity.domain, entity.object_id)[len(scope) :]
|
|
391
|
+
)
|
|
392
|
+
yield _Found(full_path, entity)
|
|
393
|
+
|
|
394
|
+
return _matches()
|
|
395
|
+
|
|
396
|
+
def find(
|
|
397
|
+
self,
|
|
398
|
+
path: str = "/",
|
|
399
|
+
*,
|
|
400
|
+
platform: str | list[str] | None = None,
|
|
401
|
+
domain: str | list[str] | None = None,
|
|
402
|
+
area: str | list[str] | None = None,
|
|
403
|
+
label: str | list[str] | None = None,
|
|
404
|
+
raw: bool = False,
|
|
405
|
+
) -> Iterator[ApiEntity] | Iterator[dict[str, Any]]:
|
|
406
|
+
"""Every entity (or raw dict, if raw=True) matching the filters under
|
|
407
|
+
`path` (relative to this view, "/" meaning this view's whole
|
|
408
|
+
subtree) - flat, skipping the directory-style one-level-at-a-time
|
|
409
|
+
grouping .keys()/indexing give you. find_paths()/find_names() are the
|
|
410
|
+
same search with just the tree-path or entity_id strings, if that's
|
|
411
|
+
all you want - see _find() for the filters.
|
|
412
|
+
|
|
413
|
+
`raw=True` yields the object as received instead of the typed,
|
|
414
|
+
renamed-to-match-Entity ApiEntity view - exactly what show(path)
|
|
415
|
+
would return for that path (the merged, cleaned registry+state
|
|
416
|
+
dict). The dict's own "entity_id" key identifies which entity it
|
|
417
|
+
came from.
|
|
418
|
+
"""
|
|
419
|
+
matches = self._find(
|
|
420
|
+
path, platform=platform, domain=domain, area=area, label=label
|
|
421
|
+
)
|
|
422
|
+
if raw:
|
|
423
|
+
return (self.show(found.path) for found in matches)
|
|
424
|
+
return (found.entity for found in matches)
|
|
425
|
+
|
|
426
|
+
def find_paths(
|
|
427
|
+
self,
|
|
428
|
+
path: str = "/",
|
|
429
|
+
*,
|
|
430
|
+
platform: str | list[str] | None = None,
|
|
431
|
+
domain: str | list[str] | None = None,
|
|
432
|
+
area: str | list[str] | None = None,
|
|
433
|
+
label: str | list[str] | None = None,
|
|
434
|
+
) -> Iterator[str]:
|
|
435
|
+
"""Just the tree-path strings from find() (e.g.
|
|
436
|
+
"/demo/light/kitchen_lights") - see _find() for the filters."""
|
|
437
|
+
return (
|
|
438
|
+
found.path
|
|
439
|
+
for found in self._find(
|
|
440
|
+
path, platform=platform, domain=domain, area=area, label=label
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def find_names(
|
|
445
|
+
self,
|
|
446
|
+
path: str = "/",
|
|
447
|
+
*,
|
|
448
|
+
platform: str | list[str] | None = None,
|
|
449
|
+
domain: str | list[str] | None = None,
|
|
450
|
+
area: str | list[str] | None = None,
|
|
451
|
+
label: str | list[str] | None = None,
|
|
452
|
+
) -> Iterator[str]:
|
|
453
|
+
"""Just the HA entity_id strings from find() (e.g.
|
|
454
|
+
"light.kitchen_lights") - see _find() for the filters. Not the same
|
|
455
|
+
as find_paths(): this is the flat entity_id, not this tree's
|
|
456
|
+
/integration/domain/object_id path."""
|
|
457
|
+
return (
|
|
458
|
+
found.entity_id
|
|
459
|
+
for found in self._find(
|
|
460
|
+
path, platform=platform, domain=domain, area=area, label=label
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def show(self, path: str) -> dict[str, Any]:
|
|
465
|
+
"""Same shape as the live tree's show() - the registry entry's own
|
|
466
|
+
fields plus `state`/`state_attributes`, cleaned the same way (None/
|
|
467
|
+
empty-collection dropped, timestamps as local ISO 8601)."""
|
|
468
|
+
_require_str(path)
|
|
469
|
+
scope = tuple(p for p in (self.integration, self.domain) if p is not None)
|
|
470
|
+
try:
|
|
471
|
+
full = scope + parse_path(path)
|
|
472
|
+
except ValueError as err:
|
|
473
|
+
raise KeyError(str(err)) from None
|
|
474
|
+
if len(full) != 3:
|
|
475
|
+
raise KeyError(f"{path!r} - show() needs a full entity path")
|
|
476
|
+
integration, domain, object_id = full
|
|
477
|
+
entity = self.cache.entities.get(f"{domain}.{object_id}")
|
|
478
|
+
if entity is None or entity.platform != integration:
|
|
479
|
+
raise KeyError(path)
|
|
480
|
+
|
|
481
|
+
data: dict[str, Any] = dict(entity.registry)
|
|
482
|
+
data["name"] = entity.name
|
|
483
|
+
data["state"] = entity.state
|
|
484
|
+
data["state_attributes"] = entity.state_attributes
|
|
485
|
+
return _clean(data)
|
|
486
|
+
|
|
487
|
+
def __repr__(self) -> str:
|
|
488
|
+
# "(api)" is just a display hint for a human reading output - the
|
|
489
|
+
# binding name and API are identical to the live tree's on purpose.
|
|
490
|
+
scope = "/".join(p for p in (self.integration, self.domain) if p is not None)
|
|
491
|
+
label = f"obj:/{scope}" if scope else "obj"
|
|
492
|
+
kind = (
|
|
493
|
+
"entities"
|
|
494
|
+
if self.domain is not None
|
|
495
|
+
else "domains"
|
|
496
|
+
if self.integration
|
|
497
|
+
else "integrations"
|
|
498
|
+
)
|
|
499
|
+
return f"<{label}: {len(self)} {kind} (api)>"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""API client mode's interactive shell: same local line editing/history as
|
|
2
|
+
repl.py, but code runs right here (LocalSession) - no `hass`, no
|
|
3
|
+
ha_repl_server/exec call, no HACS component required on the HA side at
|
|
4
|
+
all. `obj` is bound the same name as custom mode, just to an ApiObjTree
|
|
5
|
+
instead of the live ObjTree, so a snippet that only touches `obj` runs
|
|
6
|
+
unchanged in either mode.
|
|
7
|
+
|
|
8
|
+
The cache refresh happens here, between prompts, not inside ApiObjTree's own
|
|
9
|
+
Mapping methods - see api_objtree.py's module docstring for why (asyncio
|
|
10
|
+
reentrancy: this loop is the one safe place that's both async and knows when
|
|
11
|
+
"between commands" is).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import codeop
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from prompt_toolkit import PromptSession
|
|
21
|
+
from prompt_toolkit.history import FileHistory
|
|
22
|
+
from prompt_toolkit.lexers import PygmentsLexer
|
|
23
|
+
from pygments.lexers.python import PythonLexer
|
|
24
|
+
|
|
25
|
+
from .api_objtree import ApiObjTree, Cache
|
|
26
|
+
from .client import Client
|
|
27
|
+
from .local_session import LocalSession
|
|
28
|
+
from .repl import is_quit_call
|
|
29
|
+
|
|
30
|
+
HISTORY = Path.home() / ".ha_repl_api_history"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def run_api_repl(client: Client, ttl: float) -> int:
|
|
34
|
+
cache = Cache(client, ttl)
|
|
35
|
+
await cache.refresh()
|
|
36
|
+
session = LocalSession({"obj": ApiObjTree(cache)})
|
|
37
|
+
|
|
38
|
+
compiler = codeop.CommandCompiler()
|
|
39
|
+
compiler.compiler.flags |= ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
|
|
40
|
+
prompt: PromptSession[str] = PromptSession(
|
|
41
|
+
history=FileHistory(str(HISTORY)), lexer=PygmentsLexer(PythonLexer)
|
|
42
|
+
)
|
|
43
|
+
print(
|
|
44
|
+
f"Home Assistant REPL (API client mode) connected to {client.url}. "
|
|
45
|
+
"`obj` only, read-only - no `hass`. Ctrl-D to exit."
|
|
46
|
+
)
|
|
47
|
+
lines: list[str] = []
|
|
48
|
+
while True:
|
|
49
|
+
try:
|
|
50
|
+
line = await prompt.prompt_async("... " if lines else ">>> ")
|
|
51
|
+
except KeyboardInterrupt:
|
|
52
|
+
lines.clear()
|
|
53
|
+
continue
|
|
54
|
+
except EOFError:
|
|
55
|
+
return 0
|
|
56
|
+
lines.append(line)
|
|
57
|
+
source = "\n".join(lines)
|
|
58
|
+
try:
|
|
59
|
+
# None means incomplete input: keep reading continuation lines.
|
|
60
|
+
if compiler(source, "<ha_repl>", "single") is None:
|
|
61
|
+
continue
|
|
62
|
+
except SyntaxError, OverflowError, ValueError:
|
|
63
|
+
pass # run it anyway so LocalSession reports the error consistently
|
|
64
|
+
lines.clear()
|
|
65
|
+
if not source.strip():
|
|
66
|
+
continue
|
|
67
|
+
if is_quit_call(source):
|
|
68
|
+
return 0
|
|
69
|
+
if cache.is_stale():
|
|
70
|
+
await cache.refresh()
|
|
71
|
+
await session.run(source)
|