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
quilt_hp/client.py ADDED
@@ -0,0 +1,616 @@
1
+ """High-level async client for the Quilt HVAC cloud API.
2
+
3
+ Usage::
4
+
5
+ async with QuiltClient("user@example.com") as client:
6
+ await client.login(
7
+ otp_callback=lambda email: input(f"OTP for {email}: ")
8
+ )
9
+ spaces = await client.list_spaces()
10
+ for space in spaces:
11
+ print(f"{space.name}: {space.state.ambient_temperature_c}°C")
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from typing import TYPE_CHECKING, Self
18
+
19
+ from quilt_hp.auth import OtpCallback, authenticate
20
+ from quilt_hp.const import Environment
21
+ from quilt_hp.exceptions import QuiltAuthError, QuiltError
22
+ from quilt_hp.services.hds import HomeDatastoreService
23
+ from quilt_hp.services.streaming import NotifierStream
24
+ from quilt_hp.services.system import SystemInformationService
25
+ from quilt_hp.services.user import DeclaredUserType, User, UserAttributes, UserService
26
+ from quilt_hp.tokens import (
27
+ TokenRefreshContext,
28
+ TokenRefreshHooks,
29
+ TokenRefreshPolicy,
30
+ TokenRefreshReason,
31
+ TokenStoreLike,
32
+ )
33
+ from quilt_hp.transport import auth_metadata, create_channel
34
+
35
+ if TYPE_CHECKING:
36
+ from datetime import datetime
37
+
38
+ import grpc.aio
39
+
40
+ from quilt_hp.models.comfort import ComfortSetting
41
+ from quilt_hp.models.energy import SpaceEnergyMetrics
42
+ from quilt_hp.models.enums import FanSpeed, HVACMode, LouverMode
43
+ from quilt_hp.models.indoor_unit import IndoorUnit
44
+ from quilt_hp.models.schedule import ScheduleDay, ScheduleEvent, ScheduleWeek, ScheduleWeekDay
45
+ from quilt_hp.models.space import Space
46
+ from quilt_hp.models.system import SystemInfo, SystemSnapshot
47
+
48
+
49
+ class QuiltClient:
50
+ """Async client for the Quilt HVAC cloud API.
51
+
52
+ Manages authentication, gRPC channel lifecycle, and exposes high-level
53
+ methods for controlling Quilt mini-split systems.
54
+
55
+ Args:
56
+ email: Quilt account email address.
57
+ home: Optional home name filter (substring match) for multi-home
58
+ accounts.
59
+ environment: API environment (default: PROD).
60
+ snapshot_ttl_s: If > 0, cache the system snapshot for this many
61
+ seconds. Useful for read-heavy integrations. Default: 0
62
+ (no cache).
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ email: str,
68
+ *,
69
+ home: str | None = None,
70
+ environment: Environment = Environment.PROD,
71
+ snapshot_ttl_s: float = 0,
72
+ token_store: TokenStoreLike | None = None,
73
+ token_refresh_hooks: TokenRefreshHooks | None = None,
74
+ token_refresh_policy: TokenRefreshPolicy | None = None,
75
+ ) -> None:
76
+ self._email = email
77
+ self._home = home
78
+ self._environment = environment
79
+ self._snapshot_ttl_s = snapshot_ttl_s
80
+ self._token_store = token_store
81
+ self._token_refresh_hooks = token_refresh_hooks
82
+ self._token_refresh_policy = token_refresh_policy
83
+ self._token: str | None = None
84
+ self._channel: grpc.aio.Channel | None = None
85
+ self._system_id: str | None = None
86
+ self._system_name: str | None = None # name of the resolved system
87
+
88
+ # Service instances (lazily created after login)
89
+ self._hds: HomeDatastoreService | None = None
90
+ self._sysinfo: SystemInformationService | None = None
91
+ self._user_svc: UserService | None = None
92
+
93
+ # Snapshot cache
94
+ self._snapshot_cache: SystemSnapshot | None = None
95
+ self._snapshot_cached_at: float = 0.0
96
+
97
+ def get_current_token(self) -> str:
98
+ """Token provider callable for the transport interceptor."""
99
+ if self._token is None:
100
+ raise QuiltAuthError("Not authenticated. Call login() first.")
101
+ return self._token
102
+
103
+ def _ensure_channel(self) -> grpc.aio.Channel:
104
+ if self._channel is None:
105
+ self._channel = create_channel(
106
+ self,
107
+ self._environment,
108
+ refresh_callback=self.refresh_token,
109
+ )
110
+ self._hds = HomeDatastoreService(self._channel)
111
+ self._sysinfo = SystemInformationService(self._channel)
112
+ self._user_svc = UserService(self._channel)
113
+ return self._channel
114
+
115
+ # --- Auth ---
116
+
117
+ async def login(self, otp_callback: OtpCallback | None = None) -> None:
118
+ """Authenticate with the Quilt API.
119
+
120
+ If cached tokens are valid, no OTP is needed. Otherwise, the
121
+ otp_callback is called to obtain the OTP code sent to the user's email.
122
+
123
+ Args:
124
+ otp_callback: Callable that receives the email and returns the OTP.
125
+ Can be sync or async.
126
+ """
127
+ self._token = await authenticate(
128
+ self._email,
129
+ otp_callback,
130
+ self._token_store,
131
+ refresh_hooks=self._token_refresh_hooks,
132
+ refresh_policy=self._token_refresh_policy,
133
+ )
134
+ self._ensure_channel()
135
+
136
+ async def refresh_token(self, context: TokenRefreshContext | None = None) -> None:
137
+ """Refresh the auth token without OTP when refresh token is valid."""
138
+ resolved_context = context or TokenRefreshContext(
139
+ reason=TokenRefreshReason.EXPIRED_CACHED_TOKEN,
140
+ source="client",
141
+ )
142
+ self._token = await authenticate(
143
+ self._email,
144
+ token_store=self._token_store,
145
+ refresh_context=resolved_context,
146
+ refresh_hooks=self._token_refresh_hooks,
147
+ refresh_policy=self._token_refresh_policy,
148
+ )
149
+
150
+ # --- System discovery ---
151
+
152
+ @property
153
+ def system_name(self) -> str | None:
154
+ """Name of the resolved system after get_system_id() is called."""
155
+ return self._system_name
156
+
157
+ async def list_systems(self) -> list[SystemInfo]:
158
+ """List all systems the user has access to."""
159
+ self._ensure_channel()
160
+ assert self._sysinfo is not None
161
+ return await self._sysinfo.list_systems()
162
+
163
+ async def get_system_id(self, home: str | None = None) -> str:
164
+ """Get primary system ID, cached after first call unless home changes."""
165
+ target_home = home or self._home
166
+ if self._system_id is not None:
167
+ # Bypass the cache only when a different home is requested.
168
+ if not home or home == self._home:
169
+ return self._system_id
170
+
171
+ systems = await self.list_systems()
172
+ if not systems:
173
+ raise QuiltError("No systems found for this account.")
174
+
175
+ if target_home:
176
+ matches = [s for s in systems if target_home.lower() in s.name.lower()]
177
+ if not matches:
178
+ names = [s.name for s in systems]
179
+ raise QuiltError(f"No home matching {target_home!r}. Available: {names}")
180
+ self._system_id = matches[0].id
181
+ self._system_name = matches[0].name
182
+ else:
183
+ # No home filter — use the first system (primary home)
184
+ self._system_id = systems[0].id
185
+ self._system_name = systems[0].name
186
+
187
+ return self._system_id
188
+
189
+ async def get_snapshot(self, system_id: str | None = None) -> SystemSnapshot:
190
+ """Fetch a full system snapshot.
191
+
192
+ If ``snapshot_ttl_s`` was set on the client and the cached snapshot is
193
+ still fresh, the cached copy is returned without a network round-trip.
194
+ Pass ``system_id`` to query a specific system (bypasses and does not
195
+ populate the cache for the default system).
196
+ """
197
+ self._ensure_channel()
198
+ assert self._hds is not None
199
+ sid = system_id or await self.get_system_id()
200
+
201
+ # Only use cache for the default (unspecified) system_id
202
+ if system_id is None and self._snapshot_ttl_s > 0:
203
+ age = time.monotonic() - self._snapshot_cached_at
204
+ if self._snapshot_cache is not None and age < self._snapshot_ttl_s:
205
+ return self._snapshot_cache
206
+
207
+ snapshot = await self._hds.get_system(sid)
208
+
209
+ if system_id is None and self._snapshot_ttl_s > 0:
210
+ self._snapshot_cache = snapshot
211
+ self._snapshot_cached_at = time.monotonic()
212
+
213
+ return snapshot
214
+
215
+ def invalidate_snapshot(self) -> None:
216
+ """Discard the cached snapshot so the next call fetches fresh data."""
217
+ self._snapshot_cache = None
218
+ self._snapshot_cached_at = 0.0
219
+
220
+ # --- Space control ---
221
+
222
+ async def list_spaces(self) -> list[Space]:
223
+ """List all room-level spaces (excludes the root home space)."""
224
+ snapshot = await self.get_snapshot()
225
+ return snapshot.rooms
226
+
227
+ async def set_space(
228
+ self,
229
+ space: Space | str,
230
+ *,
231
+ mode: HVACMode | None = None,
232
+ heat_setpoint_c: float | None = None,
233
+ cool_setpoint_c: float | None = None,
234
+ ) -> Space:
235
+ """Update a space's HVAC mode and/or setpoints.
236
+
237
+ Args:
238
+ space: A ``Space`` object (no snapshot lookup needed) **or** a
239
+ space ID string (snapshot is fetched to resolve the object).
240
+ """
241
+ self._ensure_channel()
242
+ assert self._hds is not None
243
+ if isinstance(space, str):
244
+ snapshot = await self.get_snapshot()
245
+ resolved = next((s for s in snapshot.spaces if s.id == space), None)
246
+ if resolved is None:
247
+ raise QuiltError(f"Space {space!r} not found")
248
+ space = resolved
249
+ return await self._hds.update_space(
250
+ space,
251
+ mode=mode,
252
+ heat_setpoint_c=heat_setpoint_c,
253
+ cool_setpoint_c=cool_setpoint_c,
254
+ )
255
+
256
+ async def set_space_settings(
257
+ self,
258
+ space: Space | str,
259
+ *,
260
+ unoccupied_timeout_s: float | None = None,
261
+ occupied_timeout_s: float | None = None,
262
+ ) -> Space:
263
+ """Update a space's auto-away / auto-return timeouts.
264
+
265
+ Args:
266
+ space: A ``Space`` object or space ID string.
267
+ unoccupied_timeout_s: Seconds of no-presence before auto-away.
268
+ occupied_timeout_s: Seconds of presence before auto-return.
269
+ """
270
+ self._ensure_channel()
271
+ assert self._hds is not None
272
+ if isinstance(space, str):
273
+ snapshot = await self.get_snapshot()
274
+ resolved = next((s for s in snapshot.spaces if s.id == space), None)
275
+ if resolved is None:
276
+ raise QuiltError(f"Space {space!r} not found")
277
+ space = resolved
278
+ return await self._hds.update_space_settings(
279
+ space,
280
+ unoccupied_timeout_s=unoccupied_timeout_s,
281
+ occupied_timeout_s=occupied_timeout_s,
282
+ )
283
+
284
+ # --- Indoor unit control ---
285
+
286
+ async def list_indoor_units(self) -> list[IndoorUnit]:
287
+ """List all indoor units."""
288
+ snapshot = await self.get_snapshot()
289
+ return snapshot.indoor_units
290
+
291
+ async def set_indoor_unit(
292
+ self,
293
+ idu: IndoorUnit | str,
294
+ *,
295
+ fan_speed: FanSpeed | None = None,
296
+ louver_mode: LouverMode | None = None,
297
+ louver_position: float | None = None,
298
+ led_color_code: int | None = None,
299
+ led_brightness: float | None = None,
300
+ led_animation: int | None = None,
301
+ ) -> IndoorUnit:
302
+ """Update indoor unit controls.
303
+
304
+ Args:
305
+ idu: An ``IndoorUnit`` object (no snapshot lookup needed) **or** an
306
+ IDU ID string (snapshot is fetched to resolve the object).
307
+ """
308
+ self._ensure_channel()
309
+ assert self._hds is not None
310
+ if isinstance(idu, str):
311
+ snapshot = await self.get_snapshot()
312
+ resolved = next((u for u in snapshot.indoor_units if u.id == idu), None)
313
+ if resolved is None:
314
+ raise QuiltError(f"Indoor unit {idu!r} not found")
315
+ idu = resolved
316
+ return await self._hds.update_indoor_unit(
317
+ idu,
318
+ fan_speed=fan_speed,
319
+ louver_mode=louver_mode,
320
+ louver_position=louver_position,
321
+ led_color_code=led_color_code,
322
+ led_brightness=led_brightness,
323
+ led_animation=led_animation,
324
+ )
325
+
326
+ async def set_indoor_unit_settings(
327
+ self,
328
+ idu: IndoorUnit | str,
329
+ *,
330
+ fence_left_m: float | None = None,
331
+ fence_right_m: float | None = None,
332
+ fence_forward_m: float | None = None,
333
+ radar_height_m: float | None = None,
334
+ light_brightness_default: float | None = None,
335
+ ) -> IndoorUnit:
336
+ """Update indoor unit settings.
337
+
338
+ Args:
339
+ idu: An ``IndoorUnit`` object **or** an IDU ID string.
340
+ fence_left_m: Left boundary of presence detection zone in metres.
341
+ fence_right_m: Right boundary of presence detection zone in metres.
342
+ fence_forward_m: Forward boundary of detection zone in metres.
343
+ radar_height_m: Radar sensor mounting height from floor in metres.
344
+ light_brightness_default: Default LED brightness (0.0–1.0).
345
+
346
+ All parameters are optional; omitted fields keep their current value.
347
+ Set a fence value to 0.0 to clear it (returns to max-range detection).
348
+ """
349
+ self._ensure_channel()
350
+ assert self._hds is not None
351
+ if isinstance(idu, str):
352
+ snapshot = await self.get_snapshot()
353
+ resolved = next((u for u in snapshot.indoor_units if u.id == idu), None)
354
+ if resolved is None:
355
+ raise QuiltError(f"Indoor unit {idu!r} not found")
356
+ idu = resolved
357
+ return await self._hds.update_indoor_unit_settings(
358
+ idu,
359
+ fence_left_m=fence_left_m,
360
+ fence_right_m=fence_right_m,
361
+ fence_forward_m=fence_forward_m,
362
+ radar_height_m=radar_height_m,
363
+ light_brightness_default=light_brightness_default,
364
+ )
365
+
366
+ async def list_comfort_settings(self) -> list[ComfortSetting]:
367
+ """List all comfort presets."""
368
+ snapshot = await self.get_snapshot()
369
+ return snapshot.comfort_settings
370
+
371
+ async def update_comfort_setting(
372
+ self,
373
+ setting: ComfortSetting | str,
374
+ *,
375
+ name: str | None = None,
376
+ hvac_mode: HVACMode | None = None,
377
+ heat_setpoint_c: float | None = None,
378
+ cool_setpoint_c: float | None = None,
379
+ fan_speed: FanSpeed | None = None,
380
+ ) -> ComfortSetting:
381
+ """Update a comfort setting preset.
382
+
383
+ Args:
384
+ setting: A ``ComfortSetting`` object (no snapshot lookup needed)
385
+ **or** a setting ID string (snapshot resolves the object).
386
+ """
387
+ self._ensure_channel()
388
+ assert self._hds is not None
389
+ if isinstance(setting, str):
390
+ snapshot = await self.get_snapshot()
391
+ resolved = next((s for s in snapshot.comfort_settings if s.id == setting), None)
392
+ if resolved is None:
393
+ raise QuiltError(f"Comfort setting {setting!r} not found")
394
+ setting = resolved
395
+ return await self._hds.update_comfort_setting(
396
+ setting,
397
+ name=name,
398
+ hvac_mode=hvac_mode,
399
+ heat_setpoint_c=heat_setpoint_c,
400
+ cool_setpoint_c=cool_setpoint_c,
401
+ fan_speed=fan_speed,
402
+ )
403
+
404
+ # --- Schedules ---
405
+
406
+ async def create_schedule_day(
407
+ self,
408
+ space_id: str,
409
+ name: str,
410
+ events: list[ScheduleEvent],
411
+ ) -> ScheduleDay:
412
+ """Create a new schedule day program from domain schedule events."""
413
+ self._ensure_channel()
414
+ assert self._hds is not None
415
+ system_id = await self.get_system_id()
416
+ return await self._hds.create_schedule_day(
417
+ system_id=system_id,
418
+ space_id=space_id,
419
+ name=name,
420
+ events=events,
421
+ )
422
+
423
+ async def create_schedule_week(
424
+ self,
425
+ space_id: str,
426
+ days: list[ScheduleWeekDay] | None = None,
427
+ ) -> ScheduleWeek:
428
+ """Create a new schedule week from domain weekday mappings."""
429
+ self._ensure_channel()
430
+ assert self._hds is not None
431
+ system_id = await self.get_system_id()
432
+ return await self._hds.create_schedule_week(
433
+ system_id=system_id,
434
+ space_id=space_id,
435
+ days=days,
436
+ )
437
+
438
+ async def update_schedule_week(
439
+ self,
440
+ schedule_week_id: str,
441
+ space_id: str,
442
+ days: list[ScheduleWeekDay],
443
+ ) -> ScheduleWeek:
444
+ """Update an existing schedule week with domain weekday mappings."""
445
+ self._ensure_channel()
446
+ assert self._hds is not None
447
+ system_id = await self.get_system_id()
448
+ return await self._hds.update_schedule_week(
449
+ schedule_week_id=schedule_week_id,
450
+ system_id=system_id,
451
+ space_id=space_id,
452
+ days=days,
453
+ )
454
+
455
+ async def delete_schedule_day(self, schedule_day_id: str) -> None:
456
+ """Delete a schedule day program."""
457
+ self._ensure_channel()
458
+ assert self._hds is not None
459
+ await self._hds.delete_schedule_day(schedule_day_id)
460
+
461
+ async def update_schedule_day(
462
+ self,
463
+ schedule_day_id: str,
464
+ space_id: str,
465
+ name: str | None = None,
466
+ events: list[ScheduleEvent] | None = None,
467
+ ) -> ScheduleDay:
468
+ """Update an existing schedule day using domain schedule events."""
469
+ self._ensure_channel()
470
+ assert self._hds is not None
471
+ system_id = await self.get_system_id()
472
+ return await self._hds.update_schedule_day(
473
+ schedule_day_id=schedule_day_id,
474
+ system_id=system_id,
475
+ space_id=space_id,
476
+ name=name,
477
+ events=events,
478
+ )
479
+
480
+ async def delete_schedule_week(self, schedule_week_id: str) -> None:
481
+ """Delete a schedule week."""
482
+ self._ensure_channel()
483
+ assert self._hds is not None
484
+ await self._hds.delete_schedule_week(schedule_week_id)
485
+
486
+ async def set_schedule_execution(self, paused: bool) -> None:
487
+ """Globally pause or resume all schedules for the primary location.
488
+
489
+ Args:
490
+ paused: True to pause all schedules, False to resume.
491
+ """
492
+ self._ensure_channel()
493
+ assert self._hds is not None
494
+ snapshot = await self.get_snapshot()
495
+ loc = snapshot.primary_location
496
+ if loc is None:
497
+ raise QuiltError("No location found for this system.")
498
+ await self._hds.update_location_schedule_execution(
499
+ location_id=loc.id,
500
+ system_id=loc.system_id,
501
+ paused=paused,
502
+ )
503
+
504
+ # --- Energy ---
505
+
506
+ async def get_energy(
507
+ self,
508
+ start: datetime,
509
+ end: datetime,
510
+ system_id: str | None = None,
511
+ ) -> list[SpaceEnergyMetrics]:
512
+ """Fetch energy metrics for a time range."""
513
+ self._ensure_channel()
514
+ assert self._sysinfo is not None
515
+ sid = system_id or await self.get_system_id()
516
+ return await self._sysinfo.get_energy_metrics(sid, start, end)
517
+
518
+ # --- Streaming ---
519
+
520
+ def stream(
521
+ self,
522
+ topics: list[str],
523
+ *,
524
+ max_reconnects: int = -1,
525
+ reconnect_delay_s: float = 1.0,
526
+ ) -> NotifierStream:
527
+ """Create a NotifierStream for real-time updates.
528
+
529
+ Args:
530
+ topics: List of topic strings to subscribe to
531
+ (e.g. ``["hds/space/<uuid>"]``).
532
+ max_reconnects: Maximum automatic reconnects per disconnect. ``-1``
533
+ means unlimited (the default).
534
+ reconnect_delay_s: Initial back-off in seconds before reconnecting.
535
+ Doubles on each attempt, capped at 60 s.
536
+
537
+ Returns a ``NotifierStream`` that can be used as:
538
+
539
+ - **Background task** (for integrations)::
540
+
541
+ async with client.stream(topics) as stream:
542
+ stream.on_space_update(my_callback)
543
+ # stream runs in background, do other work here
544
+ await asyncio.sleep(3600)
545
+
546
+ - **Blocking** (for CLI / scripts)::
547
+
548
+ s = client.stream(topics)
549
+ s.on_space_update(my_callback)
550
+ await s.run_forever()
551
+ """
552
+ channel = self._ensure_channel()
553
+ return NotifierStream.create(
554
+ channel,
555
+ topics,
556
+ metadata_provider=lambda: auth_metadata(self),
557
+ authenticate=self.refresh_token,
558
+ max_reconnects=max_reconnects,
559
+ reconnect_delay_s=reconnect_delay_s,
560
+ )
561
+
562
+ # --- User ---
563
+
564
+ async def get_current_user(self) -> User:
565
+ """Get the currently authenticated user."""
566
+ self._ensure_channel()
567
+ assert self._user_svc is not None
568
+ return await self._user_svc.get_current_user()
569
+
570
+ async def update_current_user(
571
+ self,
572
+ *,
573
+ first_name: str,
574
+ last_name: str,
575
+ phone_number: str | None = None,
576
+ ) -> User:
577
+ """Update current user's first/last name and optional phone number."""
578
+ self._ensure_channel()
579
+ assert self._user_svc is not None
580
+ return await self._user_svc.update_current_user(
581
+ first_name=first_name,
582
+ last_name=last_name,
583
+ phone_number=phone_number,
584
+ )
585
+
586
+ async def get_user_attributes(self) -> UserAttributes:
587
+ """Get current user's additional attributes."""
588
+ self._ensure_channel()
589
+ assert self._user_svc is not None
590
+ return await self._user_svc.get_user_attributes()
591
+
592
+ async def patch_user_attributes(
593
+ self,
594
+ *,
595
+ declared_user_type: DeclaredUserType,
596
+ ) -> UserAttributes:
597
+ """Patch current user's additional attributes."""
598
+ self._ensure_channel()
599
+ assert self._user_svc is not None
600
+ return await self._user_svc.patch_user_attributes(
601
+ declared_user_type=declared_user_type,
602
+ )
603
+
604
+ # --- Lifecycle ---
605
+
606
+ async def close(self) -> None:
607
+ """Close the gRPC channel."""
608
+ if self._channel is not None:
609
+ await self._channel.close()
610
+ self._channel = None
611
+
612
+ async def __aenter__(self) -> Self:
613
+ return self
614
+
615
+ async def __aexit__(self, *args: object) -> None:
616
+ await self.close()
quilt_hp/const.py ADDED
@@ -0,0 +1,57 @@
1
+ """Constants for the Quilt cloud API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+
9
+ class Environment(Enum):
10
+ """Quilt API environment."""
11
+
12
+ PROD = "prod"
13
+ STAGING = "staging"
14
+ DEV = "dev"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class _EndpointConfig:
19
+ grpc_host: str
20
+ token_host: str
21
+
22
+
23
+ _ENDPOINTS: dict[Environment, _EndpointConfig] = {
24
+ Environment.PROD: _EndpointConfig(
25
+ grpc_host="api.prod.quilt.cloud:443",
26
+ token_host="token.prod.quilt.cloud",
27
+ ),
28
+ Environment.STAGING: _EndpointConfig(
29
+ grpc_host="api.staging.quilt.cloud:443",
30
+ token_host="token.staging.quilt.cloud",
31
+ ),
32
+ Environment.DEV: _EndpointConfig(
33
+ grpc_host="api.dev.quilt.cloud:443",
34
+ token_host="token.dev.quilt.cloud",
35
+ ),
36
+ }
37
+
38
+
39
+ def grpc_host(env: Environment) -> str:
40
+ """Return the gRPC host:port for the given environment."""
41
+ return _ENDPOINTS[env].grpc_host
42
+
43
+
44
+ # AWS Cognito configuration (confirmed from live capture)
45
+ COGNITO_REGION = "us-west-2"
46
+ COGNITO_CLIENT_ID = "6lef74vtc8p7pgu47nmqubd9vn"
47
+
48
+ # App version sent with every gRPC call
49
+ APP_VERSION = "1.0.25"
50
+
51
+ # gRPC keepalive settings
52
+ GRPC_CHANNEL_OPTIONS: list[tuple[str, int]] = [
53
+ ("grpc.keepalive_time_ms", 30_000),
54
+ ("grpc.keepalive_timeout_ms", 10_000),
55
+ ("grpc.keepalive_permit_without_calls", 1),
56
+ ("grpc.http2.max_pings_without_data", 0),
57
+ ]
quilt_hp/exceptions.py ADDED
@@ -0,0 +1,23 @@
1
+ """Exception hierarchy for quilt_hp."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class QuiltError(Exception):
7
+ """Base exception for all quilt_hp errors."""
8
+
9
+
10
+ class QuiltAuthError(QuiltError):
11
+ """Authentication failed (OTP rejected, refresh expired, Cognito error)."""
12
+
13
+
14
+ class QuiltConnectionError(QuiltError):
15
+ """Could not connect to the Quilt gRPC API."""
16
+
17
+
18
+ class QuiltNotFoundError(QuiltError):
19
+ """Requested resource (system, space, IDU) was not found."""
20
+
21
+
22
+ class QuiltStreamError(QuiltError):
23
+ """Error in the NotifierService bidirectional stream."""