quilt-hp-python 0.1.1__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.
- quilt_hp/__init__.py +22 -0
- quilt_hp/_paths.py +26 -0
- quilt_hp/_proto/__init__.py +0 -0
- quilt_hp/_proto/quilt_device_pairing_pb2.py +56 -0
- quilt_hp/_proto/quilt_device_pairing_pb2.pyi +317 -0
- quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +24 -0
- quilt_hp/_proto/quilt_hds_pb2.py +292 -0
- quilt_hp/_proto/quilt_hds_pb2.pyi +3947 -0
- quilt_hp/_proto/quilt_hds_pb2_grpc.py +1732 -0
- quilt_hp/_proto/quilt_notifier_pb2.py +55 -0
- quilt_hp/_proto/quilt_notifier_pb2.pyi +258 -0
- quilt_hp/_proto/quilt_notifier_pb2_grpc.py +97 -0
- quilt_hp/_proto/quilt_services_pb2.py +171 -0
- quilt_hp/_proto/quilt_services_pb2.pyi +1320 -0
- quilt_hp/_proto/quilt_services_pb2_grpc.py +1188 -0
- quilt_hp/_proto/quilt_system_pb2.py +53 -0
- quilt_hp/_proto/quilt_system_pb2.pyi +164 -0
- quilt_hp/_proto/quilt_system_pb2_grpc.py +270 -0
- quilt_hp/auth.py +244 -0
- quilt_hp/cli/__init__.py +1 -0
- quilt_hp/cli/main.py +770 -0
- quilt_hp/cli/settings.py +123 -0
- quilt_hp/cli/store.py +105 -0
- quilt_hp/cli/tui.py +2677 -0
- quilt_hp/client.py +616 -0
- quilt_hp/const.py +57 -0
- quilt_hp/exceptions.py +23 -0
- quilt_hp/models/__init__.py +85 -0
- quilt_hp/models/comfort.py +47 -0
- quilt_hp/models/controller.py +135 -0
- quilt_hp/models/energy.py +31 -0
- quilt_hp/models/enums.py +298 -0
- quilt_hp/models/indoor_unit.py +412 -0
- quilt_hp/models/outdoor_unit.py +71 -0
- quilt_hp/models/qsm.py +105 -0
- quilt_hp/models/schedule.py +98 -0
- quilt_hp/models/sensor.py +92 -0
- quilt_hp/models/software_update.py +74 -0
- quilt_hp/models/space.py +177 -0
- quilt_hp/models/system.py +451 -0
- quilt_hp/py.typed +1 -0
- quilt_hp/services/__init__.py +1 -0
- quilt_hp/services/hds.py +480 -0
- quilt_hp/services/streaming.py +561 -0
- quilt_hp/services/system.py +95 -0
- quilt_hp/services/user.py +143 -0
- quilt_hp/tokens.py +119 -0
- quilt_hp/transport.py +192 -0
- quilt_hp_python-0.1.1.dist-info/METADATA +172 -0
- quilt_hp_python-0.1.1.dist-info/RECORD +53 -0
- quilt_hp_python-0.1.1.dist-info/WHEEL +4 -0
- quilt_hp_python-0.1.1.dist-info/entry_points.txt +2 -0
- quilt_hp_python-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""NotifierService streaming - real-time HDS change subscriptions.
|
|
2
|
+
|
|
3
|
+
Handles the complex nested wire format:
|
|
4
|
+
NotifierEvent.topic (bytes) -> C1517Ta{type_url, value} ->
|
|
5
|
+
google.protobuf.Any -> HdsNotification -> HomeDatastoreObjectDiff
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextlib
|
|
12
|
+
import inspect
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Protocol, cast
|
|
17
|
+
|
|
18
|
+
import grpc
|
|
19
|
+
import grpc.aio
|
|
20
|
+
|
|
21
|
+
from quilt_hp._proto import quilt_hds_pb2 as hds
|
|
22
|
+
from quilt_hp._proto import quilt_notifier_pb2 as notifier
|
|
23
|
+
from quilt_hp._proto import quilt_notifier_pb2_grpc as notifier_grpc
|
|
24
|
+
from quilt_hp.exceptions import QuiltStreamError
|
|
25
|
+
from quilt_hp.models.controller import Controller
|
|
26
|
+
from quilt_hp.models.indoor_unit import IndoorUnit
|
|
27
|
+
from quilt_hp.models.outdoor_unit import OutdoorUnit
|
|
28
|
+
from quilt_hp.models.qsm import QuiltSmartModule
|
|
29
|
+
from quilt_hp.models.sensor import ControllerRemoteSensor, RemoteSensor
|
|
30
|
+
from quilt_hp.models.software_update import SoftwareUpdateInfo
|
|
31
|
+
from quilt_hp.models.space import Space
|
|
32
|
+
from quilt_hp.tokens import TokenRefreshContext, TokenRefreshReason
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Callbacks may be sync or async.
|
|
37
|
+
SpaceCallback = Callable[[Space], Awaitable[None] | None]
|
|
38
|
+
IndoorUnitCallback = Callable[[IndoorUnit], Awaitable[None] | None]
|
|
39
|
+
OutdoorUnitCallback = Callable[[OutdoorUnit], Awaitable[None] | None]
|
|
40
|
+
ControllerCallback = Callable[[Controller], Awaitable[None] | None]
|
|
41
|
+
QsmCallback = Callable[[QuiltSmartModule], Awaitable[None] | None]
|
|
42
|
+
RemoteSensorCallback = Callable[[RemoteSensor], Awaitable[None] | None]
|
|
43
|
+
ControllerRemoteSensorCallback = Callable[[ControllerRemoteSensor], Awaitable[None] | None]
|
|
44
|
+
SoftwareUpdateInfoCallback = Callable[[SoftwareUpdateInfo], Awaitable[None] | None]
|
|
45
|
+
ErrorCallback = Callable[[Exception], Awaitable[None] | None]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _NotifierServiceStub(Protocol):
|
|
49
|
+
def Subscribe(
|
|
50
|
+
self,
|
|
51
|
+
request_iterator: AsyncIterator[notifier.SubscribeRequest],
|
|
52
|
+
metadata: Sequence[tuple[str, str]] | None = None,
|
|
53
|
+
) -> AsyncIterator[notifier.SubscribeResponse]: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
RefreshCallback = Callable[[], Awaitable[None]] | Callable[[TokenRefreshContext], Awaitable[None]]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _invoke_refresh_callback(
|
|
60
|
+
refresh_callback: RefreshCallback, context: TokenRefreshContext
|
|
61
|
+
) -> None:
|
|
62
|
+
try:
|
|
63
|
+
has_params = bool(inspect.signature(refresh_callback).parameters)
|
|
64
|
+
except TypeError:
|
|
65
|
+
has_params = False
|
|
66
|
+
except ValueError:
|
|
67
|
+
has_params = False
|
|
68
|
+
if has_params:
|
|
69
|
+
await cast("Callable[[TokenRefreshContext], Awaitable[None]]", refresh_callback)(context)
|
|
70
|
+
return
|
|
71
|
+
await cast("Callable[[], Awaitable[None]]", refresh_callback)()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_varint(data: bytes, pos: int) -> tuple[int, int]:
|
|
75
|
+
"""Parse a protobuf varint from raw bytes."""
|
|
76
|
+
result, shift = 0, 0
|
|
77
|
+
while True:
|
|
78
|
+
b = data[pos]
|
|
79
|
+
pos += 1
|
|
80
|
+
result |= (b & 0x7F) << shift
|
|
81
|
+
if not (b & 0x80):
|
|
82
|
+
break
|
|
83
|
+
shift += 7
|
|
84
|
+
return result, pos
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_len_field(data: bytes, field_num: int) -> bytes | None:
|
|
88
|
+
"""Extract the first LEN-encoded field with the given field number."""
|
|
89
|
+
pos = 0
|
|
90
|
+
while pos < len(data):
|
|
91
|
+
tag, pos = _parse_varint(data, pos)
|
|
92
|
+
fnum = tag >> 3
|
|
93
|
+
wtype = tag & 0x7
|
|
94
|
+
if wtype == 0: # varint
|
|
95
|
+
_, pos = _parse_varint(data, pos)
|
|
96
|
+
elif wtype == 2: # length-delimited
|
|
97
|
+
length, pos = _parse_varint(data, pos)
|
|
98
|
+
if fnum == field_num:
|
|
99
|
+
return data[pos : pos + length]
|
|
100
|
+
pos += length
|
|
101
|
+
elif wtype == 5: # 32-bit
|
|
102
|
+
pos += 4
|
|
103
|
+
elif wtype == 1: # 64-bit
|
|
104
|
+
pos += 8
|
|
105
|
+
else:
|
|
106
|
+
break
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _make_subscribe_request(topics: list[str]) -> notifier.SubscribeRequest:
|
|
111
|
+
"""Build a SubscribeRequest for the given topic list."""
|
|
112
|
+
return notifier.SubscribeRequest(
|
|
113
|
+
append=notifier.TopicsMessage(
|
|
114
|
+
subscriptions=[notifier.Subscription(topic=t) for t in topics]
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _dispatch[T](cb: Callable[[T], Awaitable[None] | None], arg: T) -> None:
|
|
120
|
+
"""Call a callback, awaiting it if it returns a coroutine."""
|
|
121
|
+
result = cb(arg)
|
|
122
|
+
if asyncio.iscoroutine(result):
|
|
123
|
+
await result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(slots=True)
|
|
127
|
+
class StreamEvent:
|
|
128
|
+
"""A parsed notification event from the stream."""
|
|
129
|
+
|
|
130
|
+
topic: str
|
|
131
|
+
space: Space | None = None
|
|
132
|
+
indoor_unit: IndoorUnit | None = None
|
|
133
|
+
outdoor_unit: OutdoorUnit | None = None
|
|
134
|
+
controller: Controller | None = None
|
|
135
|
+
qsm: QuiltSmartModule | None = None
|
|
136
|
+
remote_sensor: RemoteSensor | None = None
|
|
137
|
+
controller_remote_sensor: ControllerRemoteSensor | None = None
|
|
138
|
+
software_update_info: SoftwareUpdateInfo | None = None
|
|
139
|
+
raw_bytes: bytes | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass
|
|
143
|
+
class NotifierStream:
|
|
144
|
+
"""Async manager for the NotifierService bidirectional stream.
|
|
145
|
+
|
|
146
|
+
Usage as a background task (for integrations)::
|
|
147
|
+
|
|
148
|
+
async with client.stream(topics) as stream:
|
|
149
|
+
stream.on_space_update(my_callback)
|
|
150
|
+
await asyncio.sleep(3600)
|
|
151
|
+
|
|
152
|
+
Usage blocking (for CLI / scripts)::
|
|
153
|
+
|
|
154
|
+
s = client.stream(topics)
|
|
155
|
+
s.on_space_update(my_callback)
|
|
156
|
+
await s.run_forever()
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
channel: The gRPC channel to use.
|
|
160
|
+
topics: List of topic strings to subscribe to initially.
|
|
161
|
+
metadata_provider: Optional callable that returns gRPC metadata headers.
|
|
162
|
+
authenticate: Optional async callable (no args) that refreshes the auth
|
|
163
|
+
token. When provided and the stream gets ``UNAUTHENTICATED``, the
|
|
164
|
+
callable is awaited before reconnecting.
|
|
165
|
+
max_reconnects: Maximum reconnect attempts per disconnect event.
|
|
166
|
+
``-1`` means unlimited (default).
|
|
167
|
+
reconnect_delay_s: Initial back-off delay in seconds before the first
|
|
168
|
+
reconnect. Doubles on each subsequent attempt, capped at 60 s.
|
|
169
|
+
Default: ``1.0``.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
_channel: grpc.aio.Channel
|
|
173
|
+
_topics: list[str]
|
|
174
|
+
_metadata_provider: Callable[[], Sequence[tuple[str, str]]] | None = None
|
|
175
|
+
_authenticate: RefreshCallback | None = None
|
|
176
|
+
_max_reconnects: int = -1
|
|
177
|
+
_reconnect_delay_s: float = 1.0
|
|
178
|
+
|
|
179
|
+
_space_callbacks: list[SpaceCallback] = field(default_factory=list, init=False)
|
|
180
|
+
_idu_callbacks: list[IndoorUnitCallback] = field(default_factory=list, init=False)
|
|
181
|
+
_odu_callbacks: list[OutdoorUnitCallback] = field(default_factory=list, init=False)
|
|
182
|
+
_ctrl_callbacks: list[ControllerCallback] = field(default_factory=list, init=False)
|
|
183
|
+
_qsm_callbacks: list[QsmCallback] = field(default_factory=list, init=False)
|
|
184
|
+
_rs_callbacks: list[RemoteSensorCallback] = field(default_factory=list, init=False)
|
|
185
|
+
_crs_callbacks: list[ControllerRemoteSensorCallback] = field(default_factory=list, init=False)
|
|
186
|
+
_sui_callbacks: list[SoftwareUpdateInfoCallback] = field(default_factory=list, init=False)
|
|
187
|
+
_error_callbacks: list[ErrorCallback] = field(default_factory=list, init=False)
|
|
188
|
+
_request_queue: asyncio.Queue[notifier.SubscribeRequest] = field(init=False)
|
|
189
|
+
_running: bool = field(default=False, init=False)
|
|
190
|
+
_task: asyncio.Task[None] | None = field(default=None, init=False)
|
|
191
|
+
_error: Exception | None = field(default=None, init=False)
|
|
192
|
+
|
|
193
|
+
def __post_init__(self) -> None:
|
|
194
|
+
factory = cast(
|
|
195
|
+
"Callable[[grpc.aio.Channel], _NotifierServiceStub]",
|
|
196
|
+
notifier_grpc.NotifierServiceStub,
|
|
197
|
+
)
|
|
198
|
+
self._stub: _NotifierServiceStub = factory(self._channel)
|
|
199
|
+
self._request_queue = asyncio.Queue()
|
|
200
|
+
|
|
201
|
+
# --- Public constructor (friendlier than dataclass __init__) ---
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def create(
|
|
205
|
+
cls,
|
|
206
|
+
channel: grpc.aio.Channel,
|
|
207
|
+
topics: list[str],
|
|
208
|
+
*,
|
|
209
|
+
metadata_provider: Callable[[], Sequence[tuple[str, str]]] | None = None,
|
|
210
|
+
authenticate: RefreshCallback | None = None,
|
|
211
|
+
max_reconnects: int = -1,
|
|
212
|
+
reconnect_delay_s: float = 1.0,
|
|
213
|
+
) -> NotifierStream:
|
|
214
|
+
"""Create a NotifierStream with named parameters."""
|
|
215
|
+
return cls(
|
|
216
|
+
_channel=channel,
|
|
217
|
+
_topics=list(topics),
|
|
218
|
+
_metadata_provider=metadata_provider,
|
|
219
|
+
_authenticate=authenticate,
|
|
220
|
+
_max_reconnects=max_reconnects,
|
|
221
|
+
_reconnect_delay_s=reconnect_delay_s,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# --- Callback registration ---
|
|
225
|
+
|
|
226
|
+
def on_space_update(self, callback: SpaceCallback) -> None:
|
|
227
|
+
"""Register a callback for space change events (sync or async)."""
|
|
228
|
+
self._space_callbacks.append(callback)
|
|
229
|
+
|
|
230
|
+
def on_indoor_unit_update(self, callback: IndoorUnitCallback) -> None:
|
|
231
|
+
"""Register a callback for indoor unit change events (sync or async)."""
|
|
232
|
+
self._idu_callbacks.append(callback)
|
|
233
|
+
|
|
234
|
+
def on_outdoor_unit_update(self, callback: OutdoorUnitCallback) -> None:
|
|
235
|
+
"""Register callback for outdoor unit change events."""
|
|
236
|
+
self._odu_callbacks.append(callback)
|
|
237
|
+
|
|
238
|
+
def on_controller_update(self, callback: ControllerCallback) -> None:
|
|
239
|
+
"""Register callback for controller (Dial) change events."""
|
|
240
|
+
self._ctrl_callbacks.append(callback)
|
|
241
|
+
|
|
242
|
+
def on_qsm_update(self, callback: QsmCallback) -> None:
|
|
243
|
+
"""Register callback for QuiltSmartModule change events."""
|
|
244
|
+
self._qsm_callbacks.append(callback)
|
|
245
|
+
|
|
246
|
+
def on_remote_sensor_update(self, callback: RemoteSensorCallback) -> None:
|
|
247
|
+
"""Register callback for RemoteSensor change events."""
|
|
248
|
+
self._rs_callbacks.append(callback)
|
|
249
|
+
|
|
250
|
+
def on_controller_remote_sensor_update(self, callback: ControllerRemoteSensorCallback) -> None:
|
|
251
|
+
"""Register callback for ControllerRemoteSensor change events."""
|
|
252
|
+
self._crs_callbacks.append(callback)
|
|
253
|
+
|
|
254
|
+
def on_software_update_info(self, callback: SoftwareUpdateInfoCallback) -> None:
|
|
255
|
+
"""Register callback for SoftwareUpdateInfo change events."""
|
|
256
|
+
self._sui_callbacks.append(callback)
|
|
257
|
+
|
|
258
|
+
def on_error(self, callback: ErrorCallback) -> None:
|
|
259
|
+
"""Register a callback invoked when the stream encounters a fatal error."""
|
|
260
|
+
self._error_callbacks.append(callback)
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def error(self) -> Exception | None:
|
|
264
|
+
"""The last fatal stream error, or None if the stream is healthy."""
|
|
265
|
+
return self._error
|
|
266
|
+
|
|
267
|
+
# --- Subscription management ---
|
|
268
|
+
|
|
269
|
+
async def subscribe(self, topics: list[str]) -> None:
|
|
270
|
+
"""Add more topics to the subscription (after stream is started)."""
|
|
271
|
+
self._topics.extend(topics)
|
|
272
|
+
await self._request_queue.put(_make_subscribe_request(topics))
|
|
273
|
+
|
|
274
|
+
async def unsubscribe(self, topics: list[str]) -> None:
|
|
275
|
+
"""Remove topics from the subscription."""
|
|
276
|
+
for t in topics:
|
|
277
|
+
if t in self._topics:
|
|
278
|
+
self._topics.remove(t)
|
|
279
|
+
req = notifier.SubscribeRequest(
|
|
280
|
+
remove=notifier.TopicsMessage(
|
|
281
|
+
subscriptions=[notifier.Subscription(topic=t) for t in topics]
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
await self._request_queue.put(req)
|
|
285
|
+
|
|
286
|
+
# --- Internal stream machinery ---
|
|
287
|
+
|
|
288
|
+
async def _request_iterator(
|
|
289
|
+
self,
|
|
290
|
+
) -> AsyncIterator[notifier.SubscribeRequest]:
|
|
291
|
+
"""Yield SubscribeRequests from initial subscription, then queue.
|
|
292
|
+
|
|
293
|
+
A 30-second timeout on the queue read keeps the async generator alive
|
|
294
|
+
without re-sending the topic list; gRPC channel keepalives (configured
|
|
295
|
+
in GRPC_CHANNEL_OPTIONS) handle the underlying TCP connection.
|
|
296
|
+
"""
|
|
297
|
+
yield _make_subscribe_request(self._topics)
|
|
298
|
+
while self._running:
|
|
299
|
+
try:
|
|
300
|
+
req = await asyncio.wait_for(self._request_queue.get(), timeout=30.0)
|
|
301
|
+
yield req
|
|
302
|
+
except TimeoutError:
|
|
303
|
+
continue # keepalive handled by gRPC channel options
|
|
304
|
+
|
|
305
|
+
def _parse_event(self, evt: object) -> StreamEvent | None:
|
|
306
|
+
"""Parse the complex nested wire format of a NotifierEvent."""
|
|
307
|
+
topic_bytes: bytes = getattr(cast("Any", evt), "topic", b"")
|
|
308
|
+
if not topic_bytes:
|
|
309
|
+
return None # heartbeat
|
|
310
|
+
|
|
311
|
+
type_url_bytes = _get_len_field(topic_bytes, 1) or b""
|
|
312
|
+
notif_bytes = _get_len_field(topic_bytes, 2)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
topic_str = type_url_bytes.decode("utf-8")
|
|
316
|
+
except Exception:
|
|
317
|
+
topic_str = type_url_bytes.hex()
|
|
318
|
+
|
|
319
|
+
event = StreamEvent(topic=topic_str)
|
|
320
|
+
|
|
321
|
+
if not notif_bytes:
|
|
322
|
+
return event
|
|
323
|
+
|
|
324
|
+
inner_notif = _get_len_field(notif_bytes, 2)
|
|
325
|
+
if not inner_notif:
|
|
326
|
+
event.raw_bytes = notif_bytes
|
|
327
|
+
return event
|
|
328
|
+
|
|
329
|
+
obj_diff = _get_len_field(inner_notif, 2)
|
|
330
|
+
if obj_diff:
|
|
331
|
+
space_bytes = _get_len_field(obj_diff, 3)
|
|
332
|
+
if space_bytes:
|
|
333
|
+
updated = hds.Space()
|
|
334
|
+
updated.ParseFromString(space_bytes)
|
|
335
|
+
event.space = Space.from_proto(updated)
|
|
336
|
+
|
|
337
|
+
idu_bytes = _get_len_field(obj_diff, 9)
|
|
338
|
+
if idu_bytes:
|
|
339
|
+
updated_idu = hds.IndoorUnit()
|
|
340
|
+
updated_idu.ParseFromString(idu_bytes)
|
|
341
|
+
event.indoor_unit = IndoorUnit.from_proto(updated_idu)
|
|
342
|
+
|
|
343
|
+
odu_bytes = _get_len_field(obj_diff, 6)
|
|
344
|
+
if odu_bytes:
|
|
345
|
+
updated_odu = hds.OutdoorUnit()
|
|
346
|
+
updated_odu.ParseFromString(odu_bytes)
|
|
347
|
+
event.outdoor_unit = OutdoorUnit.from_proto(updated_odu)
|
|
348
|
+
|
|
349
|
+
ctrl_bytes = _get_len_field(obj_diff, 11)
|
|
350
|
+
if ctrl_bytes:
|
|
351
|
+
updated_ctrl = hds.Controller()
|
|
352
|
+
updated_ctrl.ParseFromString(ctrl_bytes)
|
|
353
|
+
event.controller = Controller.from_proto(updated_ctrl)
|
|
354
|
+
|
|
355
|
+
qsm_bytes = _get_len_field(obj_diff, 7)
|
|
356
|
+
if qsm_bytes:
|
|
357
|
+
updated_qsm = hds.QuiltSmartModule()
|
|
358
|
+
updated_qsm.ParseFromString(qsm_bytes)
|
|
359
|
+
event.qsm = QuiltSmartModule.from_proto(updated_qsm)
|
|
360
|
+
|
|
361
|
+
rs_bytes = _get_len_field(obj_diff, 12)
|
|
362
|
+
if rs_bytes:
|
|
363
|
+
updated_rs = hds.RemoteSensor()
|
|
364
|
+
updated_rs.ParseFromString(rs_bytes)
|
|
365
|
+
event.remote_sensor = RemoteSensor.from_proto(updated_rs)
|
|
366
|
+
|
|
367
|
+
crs_bytes = _get_len_field(obj_diff, 16)
|
|
368
|
+
if crs_bytes:
|
|
369
|
+
updated_crs = hds.ControllerRemoteSensor()
|
|
370
|
+
updated_crs.ParseFromString(crs_bytes)
|
|
371
|
+
event.controller_remote_sensor = ControllerRemoteSensor.from_proto(updated_crs)
|
|
372
|
+
|
|
373
|
+
sui_bytes = _get_len_field(obj_diff, 18)
|
|
374
|
+
if sui_bytes:
|
|
375
|
+
updated_sui = hds.SoftwareUpdateInfo()
|
|
376
|
+
updated_sui.ParseFromString(sui_bytes)
|
|
377
|
+
event.software_update_info = SoftwareUpdateInfo.from_proto(updated_sui)
|
|
378
|
+
|
|
379
|
+
if (
|
|
380
|
+
event.space is None
|
|
381
|
+
and event.indoor_unit is None
|
|
382
|
+
and event.outdoor_unit is None
|
|
383
|
+
and event.controller is None
|
|
384
|
+
and event.qsm is None
|
|
385
|
+
and event.remote_sensor is None
|
|
386
|
+
and event.controller_remote_sensor is None
|
|
387
|
+
and event.software_update_info is None
|
|
388
|
+
):
|
|
389
|
+
event.raw_bytes = inner_notif
|
|
390
|
+
|
|
391
|
+
return event
|
|
392
|
+
|
|
393
|
+
async def _run_one_stream(self) -> None:
|
|
394
|
+
"""Run a single stream connection until it ends or errors."""
|
|
395
|
+
metadata = self._metadata_provider() if self._metadata_provider else None
|
|
396
|
+
call = self._stub.Subscribe(
|
|
397
|
+
self._request_iterator(),
|
|
398
|
+
metadata=metadata,
|
|
399
|
+
)
|
|
400
|
+
async for response in call:
|
|
401
|
+
for ctrl in response.control_events:
|
|
402
|
+
event_name = notifier.ControlEventType.Name(ctrl.type)
|
|
403
|
+
logger.debug("Control event: %s topics=%s", event_name, list(ctrl.topics))
|
|
404
|
+
|
|
405
|
+
for evt in response.notifier_events:
|
|
406
|
+
parsed = self._parse_event(evt)
|
|
407
|
+
if parsed is None:
|
|
408
|
+
continue
|
|
409
|
+
if parsed.space is not None:
|
|
410
|
+
for space_cb in self._space_callbacks:
|
|
411
|
+
try:
|
|
412
|
+
await _dispatch(space_cb, parsed.space)
|
|
413
|
+
except Exception:
|
|
414
|
+
logger.exception("Error in space callback")
|
|
415
|
+
if parsed.indoor_unit is not None:
|
|
416
|
+
for idu_cb in self._idu_callbacks:
|
|
417
|
+
try:
|
|
418
|
+
await _dispatch(idu_cb, parsed.indoor_unit)
|
|
419
|
+
except Exception:
|
|
420
|
+
logger.exception("Error in indoor unit callback")
|
|
421
|
+
if parsed.outdoor_unit is not None:
|
|
422
|
+
for odu_cb in self._odu_callbacks:
|
|
423
|
+
try:
|
|
424
|
+
await _dispatch(odu_cb, parsed.outdoor_unit)
|
|
425
|
+
except Exception:
|
|
426
|
+
logger.exception("Error in outdoor unit callback")
|
|
427
|
+
if parsed.controller is not None:
|
|
428
|
+
for ctrl_cb in self._ctrl_callbacks:
|
|
429
|
+
try:
|
|
430
|
+
await _dispatch(ctrl_cb, parsed.controller)
|
|
431
|
+
except Exception:
|
|
432
|
+
logger.exception("Error in controller callback")
|
|
433
|
+
if parsed.qsm is not None:
|
|
434
|
+
for qsm_cb in self._qsm_callbacks:
|
|
435
|
+
try:
|
|
436
|
+
await _dispatch(qsm_cb, parsed.qsm)
|
|
437
|
+
except Exception:
|
|
438
|
+
logger.exception("Error in QSM callback")
|
|
439
|
+
if parsed.remote_sensor is not None:
|
|
440
|
+
for rs_cb in self._rs_callbacks:
|
|
441
|
+
try:
|
|
442
|
+
await _dispatch(rs_cb, parsed.remote_sensor)
|
|
443
|
+
except Exception:
|
|
444
|
+
logger.exception("Error in remote sensor callback")
|
|
445
|
+
if parsed.controller_remote_sensor is not None:
|
|
446
|
+
for crs_cb in self._crs_callbacks:
|
|
447
|
+
try:
|
|
448
|
+
await _dispatch(crs_cb, parsed.controller_remote_sensor)
|
|
449
|
+
except Exception:
|
|
450
|
+
logger.exception("Error in controller remote sensor callback")
|
|
451
|
+
if parsed.software_update_info is not None:
|
|
452
|
+
for sui_cb in self._sui_callbacks:
|
|
453
|
+
try:
|
|
454
|
+
await _dispatch(sui_cb, parsed.software_update_info)
|
|
455
|
+
except Exception:
|
|
456
|
+
logger.exception("Error in software update info callback")
|
|
457
|
+
|
|
458
|
+
async def _run_stream_with_reconnect(self) -> None:
|
|
459
|
+
"""Run the stream with automatic reconnect and exponential back-off."""
|
|
460
|
+
attempt = 0
|
|
461
|
+
delay = self._reconnect_delay_s
|
|
462
|
+
|
|
463
|
+
while self._running:
|
|
464
|
+
try:
|
|
465
|
+
self._error = None
|
|
466
|
+
await self._run_one_stream()
|
|
467
|
+
# Clean exit (stream ended without error) — stop.
|
|
468
|
+
break
|
|
469
|
+
except grpc.aio.AioRpcError as exc:
|
|
470
|
+
if not self._running:
|
|
471
|
+
break
|
|
472
|
+
is_unauth = exc.code() == grpc.StatusCode.UNAUTHENTICATED
|
|
473
|
+
can_retry = self._max_reconnects < 0 or attempt < self._max_reconnects
|
|
474
|
+
|
|
475
|
+
if is_unauth and self._authenticate is not None and can_retry:
|
|
476
|
+
logger.warning(
|
|
477
|
+
"Stream got UNAUTHENTICATED; refreshing token (attempt %d)",
|
|
478
|
+
attempt + 1,
|
|
479
|
+
)
|
|
480
|
+
try:
|
|
481
|
+
context = TokenRefreshContext(
|
|
482
|
+
reason=TokenRefreshReason.STREAM_UNAUTHENTICATED,
|
|
483
|
+
source="streaming",
|
|
484
|
+
attempt=attempt + 1,
|
|
485
|
+
)
|
|
486
|
+
await _invoke_refresh_callback(self._authenticate, context)
|
|
487
|
+
except Exception:
|
|
488
|
+
logger.exception("Token refresh failed; giving up stream")
|
|
489
|
+
self._error = exc
|
|
490
|
+
break
|
|
491
|
+
elif can_retry:
|
|
492
|
+
logger.warning(
|
|
493
|
+
"Stream error %s: %s; reconnecting in %.1fs (attempt %d)",
|
|
494
|
+
exc.code(),
|
|
495
|
+
exc.details(),
|
|
496
|
+
delay,
|
|
497
|
+
attempt + 1,
|
|
498
|
+
)
|
|
499
|
+
else:
|
|
500
|
+
logger.error(
|
|
501
|
+
"Stream error %s: %s; max reconnects reached",
|
|
502
|
+
exc.code(),
|
|
503
|
+
exc.details(),
|
|
504
|
+
)
|
|
505
|
+
self._error = QuiltStreamError(f"Stream error: {exc.code()} - {exc.details()}")
|
|
506
|
+
break
|
|
507
|
+
|
|
508
|
+
await asyncio.sleep(delay)
|
|
509
|
+
delay = min(delay * 2, 60.0)
|
|
510
|
+
attempt += 1
|
|
511
|
+
# Reset request queue so the next connection re-subscribes.
|
|
512
|
+
self._request_queue = asyncio.Queue()
|
|
513
|
+
|
|
514
|
+
if self._error is not None:
|
|
515
|
+
for cb in self._error_callbacks:
|
|
516
|
+
try:
|
|
517
|
+
await _dispatch(cb, self._error)
|
|
518
|
+
except Exception:
|
|
519
|
+
logger.exception("Error in error callback")
|
|
520
|
+
if not self._error_callbacks:
|
|
521
|
+
# Propagate to the task so the caller can observe it
|
|
522
|
+
raise self._error
|
|
523
|
+
|
|
524
|
+
# --- Lifecycle ---
|
|
525
|
+
|
|
526
|
+
async def run_forever(self) -> None:
|
|
527
|
+
"""Run the stream inline (blocking) until cancelled or fatal error."""
|
|
528
|
+
self._running = True
|
|
529
|
+
await self._run_stream_with_reconnect()
|
|
530
|
+
|
|
531
|
+
async def start(self) -> None:
|
|
532
|
+
"""Start the stream listener as a background task."""
|
|
533
|
+
if self._running:
|
|
534
|
+
return
|
|
535
|
+
self._running = True
|
|
536
|
+
self._task = asyncio.create_task(self._run_stream_with_reconnect())
|
|
537
|
+
self._task.add_done_callback(self._on_task_done)
|
|
538
|
+
|
|
539
|
+
def _on_task_done(self, task: asyncio.Task[None]) -> None:
|
|
540
|
+
"""Log unhandled task exceptions so they aren't silently swallowed."""
|
|
541
|
+
if task.cancelled():
|
|
542
|
+
return
|
|
543
|
+
exc = task.exception()
|
|
544
|
+
if exc is not None:
|
|
545
|
+
logger.error("NotifierStream task exited with error: %s", exc)
|
|
546
|
+
|
|
547
|
+
async def stop(self) -> None:
|
|
548
|
+
"""Stop the stream listener."""
|
|
549
|
+
self._running = False
|
|
550
|
+
if self._task is not None:
|
|
551
|
+
self._task.cancel()
|
|
552
|
+
with contextlib.suppress(asyncio.CancelledError, QuiltStreamError):
|
|
553
|
+
await self._task
|
|
554
|
+
self._task = None
|
|
555
|
+
|
|
556
|
+
async def __aenter__(self) -> NotifierStream:
|
|
557
|
+
await self.start()
|
|
558
|
+
return self
|
|
559
|
+
|
|
560
|
+
async def __aexit__(self, *args: object) -> None:
|
|
561
|
+
await self.stop()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""SystemService and SystemInformationService.
|
|
2
|
+
|
|
3
|
+
Provides system listing and energy metrics.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import TYPE_CHECKING, Protocol, cast
|
|
11
|
+
|
|
12
|
+
import grpc.aio
|
|
13
|
+
from google.protobuf.timestamp_pb2 import Timestamp
|
|
14
|
+
|
|
15
|
+
from quilt_hp._proto import quilt_services_pb2 as svc
|
|
16
|
+
from quilt_hp._proto import quilt_services_pb2_grpc as svc_grpc
|
|
17
|
+
from quilt_hp.exceptions import QuiltError
|
|
18
|
+
from quilt_hp.models.energy import EnergyBucket, SpaceEnergyMetrics
|
|
19
|
+
from quilt_hp.models.system import SystemInfo
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from datetime import datetime as _datetime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _SystemInformationServiceStub(Protocol):
|
|
26
|
+
async def ListSystems(
|
|
27
|
+
self, request: svc.ListSystemInformationRequest
|
|
28
|
+
) -> svc.ListSystemInformationResponse: ...
|
|
29
|
+
|
|
30
|
+
async def GetEnergyMetrics(
|
|
31
|
+
self, request: svc.GetEnergyMetricsRequest
|
|
32
|
+
) -> svc.GetEnergyMetricsResponse: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SystemInformationService:
|
|
36
|
+
"""Async wrapper for SystemInformationService gRPC methods."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, channel: grpc.aio.Channel) -> None:
|
|
39
|
+
factory = cast(
|
|
40
|
+
"Callable[[grpc.aio.Channel], _SystemInformationServiceStub]",
|
|
41
|
+
svc_grpc.SystemInformationServiceStub,
|
|
42
|
+
)
|
|
43
|
+
self._stub: _SystemInformationServiceStub = factory(channel)
|
|
44
|
+
|
|
45
|
+
async def list_systems(self) -> list[SystemInfo]:
|
|
46
|
+
"""List all systems the authenticated user has access to."""
|
|
47
|
+
try:
|
|
48
|
+
resp = await self._stub.ListSystems(svc.ListSystemInformationRequest())
|
|
49
|
+
except grpc.aio.AioRpcError as exc:
|
|
50
|
+
raise QuiltError(f"ListSystems failed: {exc.details()}") from exc
|
|
51
|
+
return [
|
|
52
|
+
SystemInfo(
|
|
53
|
+
id=s.id,
|
|
54
|
+
name=s.name,
|
|
55
|
+
timezone=s.tz_identifier,
|
|
56
|
+
)
|
|
57
|
+
for s in resp.systems
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
async def get_energy_metrics(
|
|
61
|
+
self,
|
|
62
|
+
system_id: str,
|
|
63
|
+
start: _datetime,
|
|
64
|
+
end: _datetime,
|
|
65
|
+
) -> list[SpaceEnergyMetrics]:
|
|
66
|
+
"""Fetch hourly energy metrics for all spaces in a time range."""
|
|
67
|
+
start_ts = Timestamp()
|
|
68
|
+
start_ts.FromSeconds(int(start.timestamp()))
|
|
69
|
+
end_ts = Timestamp()
|
|
70
|
+
end_ts.FromSeconds(int(end.timestamp()))
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
result = await self._stub.GetEnergyMetrics(
|
|
74
|
+
svc.GetEnergyMetricsRequest(
|
|
75
|
+
system_id=system_id,
|
|
76
|
+
start_time=start_ts,
|
|
77
|
+
end_time=end_ts,
|
|
78
|
+
preferred_resolution=svc.TIME_RESOLUTION_HOURLY,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
except grpc.aio.AioRpcError as exc:
|
|
82
|
+
raise QuiltError(f"GetEnergyMetrics failed: {exc.details()}") from exc
|
|
83
|
+
|
|
84
|
+
metrics = []
|
|
85
|
+
for sm in result.space_energy_metrics:
|
|
86
|
+
buckets = [
|
|
87
|
+
EnergyBucket(
|
|
88
|
+
start_time=b.start_time.ToDatetime(tzinfo=datetime.UTC),
|
|
89
|
+
energy_kwh=b.energy_kwh,
|
|
90
|
+
status=b.status,
|
|
91
|
+
)
|
|
92
|
+
for b in sm.energy_buckets
|
|
93
|
+
]
|
|
94
|
+
metrics.append(SpaceEnergyMetrics(space_id=sm.space_id, buckets=buckets))
|
|
95
|
+
return metrics
|