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
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."""
|