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/auth.py ADDED
@@ -0,0 +1,244 @@
1
+ """Cognito authentication — email OTP login and token refresh.
2
+
3
+ The OTP callback is injectable so that library consumers can provide their own
4
+ UI (CLI prompt, web form, etc.) without depending on stdin.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import inspect
11
+ import time
12
+ from collections.abc import Awaitable, Callable
13
+ from functools import partial
14
+ from typing import Protocol, cast
15
+
16
+ import boto3
17
+ from botocore.exceptions import ClientError
18
+
19
+ from quilt_hp.const import COGNITO_CLIENT_ID, COGNITO_REGION
20
+ from quilt_hp.exceptions import QuiltAuthError
21
+ from quilt_hp.tokens import (
22
+ CachedTokens,
23
+ RefreshFailureAction,
24
+ TokenRefreshContext,
25
+ TokenRefreshHooks,
26
+ TokenRefreshPolicy,
27
+ TokenRefreshReason,
28
+ TokenStoreLike,
29
+ )
30
+
31
+ # Type for the OTP callback: receives the email, returns the OTP code.
32
+ # Supports both sync and async callables.
33
+ type OtpCallback = Callable[[str], str | Awaitable[str]]
34
+ type CognitoAuthResult = dict[str, str | int]
35
+
36
+
37
+ class _CognitoClient(Protocol):
38
+ def initiate_auth(self, **kwargs: object) -> dict[str, object]: ...
39
+ def respond_to_auth_challenge(self, **kwargs: object) -> dict[str, object]: ...
40
+
41
+
42
+ async def _resolve_otp(callback: OtpCallback, email: str) -> str:
43
+ """Call the OTP callback, handling both sync and async variants."""
44
+ result = callback(email)
45
+ if isinstance(result, str):
46
+ return result
47
+ return await result
48
+
49
+
50
+ def _require_str(result: CognitoAuthResult, key: str) -> str:
51
+ value = result.get(key)
52
+ if isinstance(value, str):
53
+ return value
54
+ raise QuiltAuthError(f"Authentication response missing valid {key!r}.")
55
+
56
+
57
+ def _expires_in_s(result: CognitoAuthResult) -> int:
58
+ value = result.get("ExpiresIn", 3600)
59
+ return value if isinstance(value, int) else 3600
60
+
61
+
62
+ def _make_cognito_client() -> _CognitoClient:
63
+ """Create a boto3 Cognito Identity Provider client."""
64
+ return cast(
65
+ "_CognitoClient",
66
+ boto3.client("cognito-idp", region_name=COGNITO_REGION),
67
+ )
68
+
69
+
70
+ async def _do_otp_login(email: str, otp_callback: OtpCallback) -> CognitoAuthResult:
71
+ """Full OTP flow. Returns Cognito AuthenticationResult dict."""
72
+ loop = asyncio.get_running_loop()
73
+ cognito = _make_cognito_client()
74
+
75
+ # Step 1: Initiate CUSTOM_AUTH
76
+ try:
77
+ resp = await loop.run_in_executor(
78
+ None,
79
+ partial(
80
+ cognito.initiate_auth,
81
+ AuthFlow="CUSTOM_AUTH",
82
+ AuthParameters={"USERNAME": email},
83
+ ClientId=COGNITO_CLIENT_ID,
84
+ ClientMetadata={},
85
+ ),
86
+ )
87
+ except ClientError as exc:
88
+ error = exc.response["Error"]
89
+ raise QuiltAuthError(f"Auth failed [{error['Code']}]: {error['Message']}") from exc
90
+
91
+ if resp.get("ChallengeName") != "CUSTOM_CHALLENGE":
92
+ raise QuiltAuthError(f"Unexpected challenge: {resp.get('ChallengeName')}")
93
+
94
+ session = resp.get("Session")
95
+ if not isinstance(session, str):
96
+ raise QuiltAuthError("Authentication challenge missing valid Session.")
97
+
98
+ # Step 2: Get OTP from the caller
99
+ otp = await _resolve_otp(otp_callback, email)
100
+
101
+ # Step 3: Respond to challenge
102
+ try:
103
+ resp2 = await loop.run_in_executor(
104
+ None,
105
+ partial(
106
+ cognito.respond_to_auth_challenge,
107
+ ClientId=COGNITO_CLIENT_ID,
108
+ ChallengeName="CUSTOM_CHALLENGE",
109
+ Session=session,
110
+ ChallengeResponses={"USERNAME": email, "ANSWER": otp},
111
+ ClientMetadata={},
112
+ ),
113
+ )
114
+ except ClientError as exc:
115
+ error = exc.response["Error"]
116
+ raise QuiltAuthError(
117
+ f"OTP challenge failed [{error['Code']}]: {error['Message']}"
118
+ ) from exc
119
+
120
+ auth_result = resp2.get("AuthenticationResult")
121
+ if not isinstance(auth_result, dict):
122
+ raise QuiltAuthError("Authentication response missing AuthenticationResult.")
123
+ return cast("CognitoAuthResult", auth_result)
124
+
125
+
126
+ async def _do_refresh(refresh_token: str) -> CognitoAuthResult:
127
+ """Use a refresh token to get a new IdToken."""
128
+ loop = asyncio.get_running_loop()
129
+ cognito = _make_cognito_client()
130
+ try:
131
+ resp = await loop.run_in_executor(
132
+ None,
133
+ partial(
134
+ cognito.initiate_auth,
135
+ AuthFlow="REFRESH_TOKEN_AUTH",
136
+ AuthParameters={"REFRESH_TOKEN": refresh_token},
137
+ ClientId=COGNITO_CLIENT_ID,
138
+ ),
139
+ )
140
+ auth_result = resp.get("AuthenticationResult")
141
+ if not isinstance(auth_result, dict):
142
+ raise QuiltAuthError("Refresh response missing AuthenticationResult.")
143
+ return cast("CognitoAuthResult", auth_result)
144
+ except ClientError as exc:
145
+ error = exc.response["Error"]
146
+ raise QuiltAuthError(
147
+ f"Token refresh failed [{error['Code']}]: {error['Message']}"
148
+ ) from exc
149
+
150
+
151
+ async def _load_tokens(token_store: TokenStoreLike, email: str) -> CachedTokens | None:
152
+ load = token_store.load
153
+ if inspect.iscoroutinefunction(load):
154
+ return await cast("Callable[[str], Awaitable[CachedTokens | None]]", load)(email)
155
+ sync_load = cast("Callable[[str], CachedTokens | None]", load)
156
+ return await asyncio.to_thread(sync_load, email)
157
+
158
+
159
+ async def _save_tokens(token_store: TokenStoreLike, email: str, tokens: CachedTokens) -> None:
160
+ save = token_store.save
161
+ if inspect.iscoroutinefunction(save):
162
+ await cast(
163
+ "Callable[[str, CachedTokens], Awaitable[None]]",
164
+ save,
165
+ )(email, tokens)
166
+ return
167
+ sync_save = cast("Callable[[str, CachedTokens], None]", save)
168
+ await asyncio.to_thread(sync_save, email, tokens)
169
+
170
+
171
+ async def authenticate(
172
+ email: str,
173
+ otp_callback: OtpCallback | None = None,
174
+ token_store: TokenStoreLike | None = None,
175
+ *,
176
+ refresh_context: TokenRefreshContext | None = None,
177
+ refresh_hooks: TokenRefreshHooks | None = None,
178
+ refresh_policy: TokenRefreshPolicy | None = None,
179
+ ) -> str:
180
+ """Return a valid Cognito IdToken (JWT) for the given email.
181
+
182
+ 1. If *token_store* has a valid cached token → return it.
183
+ 2. If *token_store* has a refresh token → use REFRESH_TOKEN_AUTH.
184
+ 3. Fall back to the full OTP flow (requires *otp_callback*).
185
+
186
+ Token persistence is delegated to *token_store*. Pass ``None`` for
187
+ purely in-memory/stateless operation (caller handles caching).
188
+ """
189
+ now = time.time()
190
+ cached = await _load_tokens(token_store, email) if token_store else None
191
+
192
+ # 1. Valid cached IdToken
193
+ if cached is not None and not cached.is_expired:
194
+ return cached.id_token
195
+
196
+ # 2. Refresh token
197
+ if cached is not None and cached.refresh_token:
198
+ context = refresh_context or TokenRefreshContext(
199
+ reason=TokenRefreshReason.EXPIRED_CACHED_TOKEN,
200
+ source="authenticate",
201
+ )
202
+ if refresh_hooks is not None:
203
+ await refresh_hooks.on_refresh_start(context)
204
+ try:
205
+ result = await _do_refresh(cached.refresh_token)
206
+ except Exception as exc:
207
+ if refresh_hooks is not None:
208
+ await refresh_hooks.on_refresh_failure(context, exc)
209
+ action = (
210
+ refresh_policy.on_refresh_failure(context, exc)
211
+ if refresh_policy is not None
212
+ else RefreshFailureAction.FALLBACK_TO_OTP
213
+ )
214
+ if action == RefreshFailureAction.RAISE or otp_callback is None:
215
+ raise
216
+ else:
217
+ tokens = CachedTokens(
218
+ id_token=_require_str(result, "IdToken"),
219
+ refresh_token=cached.refresh_token,
220
+ expires_at=now + _expires_in_s(result),
221
+ )
222
+ if token_store:
223
+ await _save_tokens(token_store, email, tokens)
224
+ if refresh_hooks is not None:
225
+ await refresh_hooks.on_refresh_success(context, tokens)
226
+ return tokens.id_token
227
+
228
+ # 3. Full OTP login
229
+ if otp_callback is None:
230
+ raise QuiltAuthError(
231
+ "No valid cached token and no otp_callback provided. "
232
+ "Call authenticate() with an otp_callback to perform the "
233
+ "OTP login flow."
234
+ )
235
+
236
+ result = await _do_otp_login(email, otp_callback)
237
+ tokens = CachedTokens(
238
+ id_token=_require_str(result, "IdToken"),
239
+ refresh_token=_require_str(result, "RefreshToken") if "RefreshToken" in result else "",
240
+ expires_at=now + _expires_in_s(result),
241
+ )
242
+ if token_store:
243
+ await _save_tokens(token_store, email, tokens)
244
+ return tokens.id_token
@@ -0,0 +1 @@
1
+ """CLI package."""