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.
Files changed (53) hide show
  1. quilt_hp/__init__.py +22 -0
  2. quilt_hp/_paths.py +26 -0
  3. quilt_hp/_proto/__init__.py +0 -0
  4. quilt_hp/_proto/quilt_device_pairing_pb2.py +56 -0
  5. quilt_hp/_proto/quilt_device_pairing_pb2.pyi +317 -0
  6. quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +24 -0
  7. quilt_hp/_proto/quilt_hds_pb2.py +292 -0
  8. quilt_hp/_proto/quilt_hds_pb2.pyi +3947 -0
  9. quilt_hp/_proto/quilt_hds_pb2_grpc.py +1732 -0
  10. quilt_hp/_proto/quilt_notifier_pb2.py +55 -0
  11. quilt_hp/_proto/quilt_notifier_pb2.pyi +258 -0
  12. quilt_hp/_proto/quilt_notifier_pb2_grpc.py +97 -0
  13. quilt_hp/_proto/quilt_services_pb2.py +171 -0
  14. quilt_hp/_proto/quilt_services_pb2.pyi +1320 -0
  15. quilt_hp/_proto/quilt_services_pb2_grpc.py +1188 -0
  16. quilt_hp/_proto/quilt_system_pb2.py +53 -0
  17. quilt_hp/_proto/quilt_system_pb2.pyi +164 -0
  18. quilt_hp/_proto/quilt_system_pb2_grpc.py +270 -0
  19. quilt_hp/auth.py +244 -0
  20. quilt_hp/cli/__init__.py +1 -0
  21. quilt_hp/cli/main.py +770 -0
  22. quilt_hp/cli/settings.py +123 -0
  23. quilt_hp/cli/store.py +105 -0
  24. quilt_hp/cli/tui.py +2677 -0
  25. quilt_hp/client.py +616 -0
  26. quilt_hp/const.py +57 -0
  27. quilt_hp/exceptions.py +23 -0
  28. quilt_hp/models/__init__.py +85 -0
  29. quilt_hp/models/comfort.py +47 -0
  30. quilt_hp/models/controller.py +135 -0
  31. quilt_hp/models/energy.py +31 -0
  32. quilt_hp/models/enums.py +298 -0
  33. quilt_hp/models/indoor_unit.py +412 -0
  34. quilt_hp/models/outdoor_unit.py +71 -0
  35. quilt_hp/models/qsm.py +105 -0
  36. quilt_hp/models/schedule.py +98 -0
  37. quilt_hp/models/sensor.py +92 -0
  38. quilt_hp/models/software_update.py +74 -0
  39. quilt_hp/models/space.py +177 -0
  40. quilt_hp/models/system.py +451 -0
  41. quilt_hp/py.typed +1 -0
  42. quilt_hp/services/__init__.py +1 -0
  43. quilt_hp/services/hds.py +480 -0
  44. quilt_hp/services/streaming.py +561 -0
  45. quilt_hp/services/system.py +95 -0
  46. quilt_hp/services/user.py +143 -0
  47. quilt_hp/tokens.py +119 -0
  48. quilt_hp/transport.py +192 -0
  49. quilt_hp_python-0.1.1.dist-info/METADATA +172 -0
  50. quilt_hp_python-0.1.1.dist-info/RECORD +53 -0
  51. quilt_hp_python-0.1.1.dist-info/WHEEL +4 -0
  52. quilt_hp_python-0.1.1.dist-info/entry_points.txt +2 -0
  53. 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