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/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
|
quilt_hp/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package."""
|