pyatomberg 0.1.0__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.
- pyatomberg/__init__.py +79 -0
- pyatomberg/auth/__init__.py +6 -0
- pyatomberg/auth/cognito.py +177 -0
- pyatomberg/auth/developer.py +121 -0
- pyatomberg/auth/interactive_oauth.py +100 -0
- pyatomberg/client.py +230 -0
- pyatomberg/const.py +43 -0
- pyatomberg/crypto.py +83 -0
- pyatomberg/exceptions.py +28 -0
- pyatomberg/fan/__init__.py +6 -0
- pyatomberg/fan/device.py +151 -0
- pyatomberg/fan/udp_listener.py +149 -0
- pyatomberg/lock/__init__.py +39 -0
- pyatomberg/lock/cloud_sync.py +165 -0
- pyatomberg/lock/dynamic_pin.py +216 -0
- pyatomberg/lock/hsj_protocol.py +287 -0
- pyatomberg/lock/pairing.py +193 -0
- pyatomberg/models/__init__.py +17 -0
- pyatomberg/models/device.py +62 -0
- pyatomberg-0.1.0.dist-info/METADATA +336 -0
- pyatomberg-0.1.0.dist-info/RECORD +24 -0
- pyatomberg-0.1.0.dist-info/WHEEL +4 -0
- pyatomberg-0.1.0.dist-info/entry_points.txt +2 -0
- pyatomberg-0.1.0.dist-info/licenses/LICENSE +622 -0
pyatomberg/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""pyatomberg: Python library for Atomberg smart home authentication, discovery, and protocol codecs."""
|
|
2
|
+
|
|
3
|
+
from pyatomberg.auth.cognito import CognitoOAuthHandler
|
|
4
|
+
from pyatomberg.auth.developer import DeveloperTokenHandler
|
|
5
|
+
from pyatomberg.client import AtombergClient
|
|
6
|
+
from pyatomberg.exceptions import (
|
|
7
|
+
AtombergAuthError,
|
|
8
|
+
AtombergBLEError,
|
|
9
|
+
AtombergConnectionError,
|
|
10
|
+
AtombergCryptoError,
|
|
11
|
+
AtombergDeviceOfflineError,
|
|
12
|
+
AtombergError,
|
|
13
|
+
AtombergPairingError,
|
|
14
|
+
)
|
|
15
|
+
from pyatomberg.fan.device import AtombergFan
|
|
16
|
+
from pyatomberg.fan.udp_listener import AtombergUDPListener
|
|
17
|
+
from pyatomberg.lock.cloud_sync import AtombergCloudSync
|
|
18
|
+
from pyatomberg.lock.dynamic_pin import (
|
|
19
|
+
AtombergDynamicPinClient,
|
|
20
|
+
calculate_offline_otp,
|
|
21
|
+
quantize_expiration_time,
|
|
22
|
+
)
|
|
23
|
+
from pyatomberg.lock.hsj_protocol import (
|
|
24
|
+
DEFAULT_SLOT_MAPPINGS,
|
|
25
|
+
LiveEvent,
|
|
26
|
+
build_hsj_packet,
|
|
27
|
+
format_ist_time,
|
|
28
|
+
parse_hsj_packet,
|
|
29
|
+
parse_live_event,
|
|
30
|
+
parse_log_record,
|
|
31
|
+
slot_name,
|
|
32
|
+
)
|
|
33
|
+
from pyatomberg.lock.pairing import (
|
|
34
|
+
PairingResult,
|
|
35
|
+
extract_keys_from_btsnoop,
|
|
36
|
+
)
|
|
37
|
+
from pyatomberg.models.device import (
|
|
38
|
+
AtombergDeviceModel,
|
|
39
|
+
DeviceType,
|
|
40
|
+
FanState,
|
|
41
|
+
LockState,
|
|
42
|
+
WaterPurifierState,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__version__ = "0.1.0"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"PairingResult",
|
|
49
|
+
"extract_keys_from_btsnoop",
|
|
50
|
+
"AtombergCloudSync",
|
|
51
|
+
"AtombergDynamicPinClient",
|
|
52
|
+
"calculate_offline_otp",
|
|
53
|
+
"quantize_expiration_time",
|
|
54
|
+
"LiveEvent",
|
|
55
|
+
"build_hsj_packet",
|
|
56
|
+
"parse_hsj_packet",
|
|
57
|
+
"parse_live_event",
|
|
58
|
+
"parse_log_record",
|
|
59
|
+
"slot_name",
|
|
60
|
+
"format_ist_time",
|
|
61
|
+
"DEFAULT_SLOT_MAPPINGS",
|
|
62
|
+
"AtombergClient",
|
|
63
|
+
"AtombergFan",
|
|
64
|
+
"AtombergUDPListener",
|
|
65
|
+
"DeveloperTokenHandler",
|
|
66
|
+
"CognitoOAuthHandler",
|
|
67
|
+
"AtombergError",
|
|
68
|
+
"AtombergAuthError",
|
|
69
|
+
"AtombergConnectionError",
|
|
70
|
+
"AtombergDeviceOfflineError",
|
|
71
|
+
"AtombergBLEError",
|
|
72
|
+
"AtombergCryptoError",
|
|
73
|
+
"AtombergPairingError",
|
|
74
|
+
"AtombergDeviceModel",
|
|
75
|
+
"DeviceType",
|
|
76
|
+
"FanState",
|
|
77
|
+
"LockState",
|
|
78
|
+
"WaterPurifierState",
|
|
79
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""AWS Cognito Hosted UI OAuth2 authentication handler."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from pyatomberg.const import (
|
|
14
|
+
COGNITO_AUTHORIZE_URL,
|
|
15
|
+
COGNITO_CLIENT_ID,
|
|
16
|
+
COGNITO_REDIRECT_URI,
|
|
17
|
+
COGNITO_TOKEN_URL,
|
|
18
|
+
)
|
|
19
|
+
from pyatomberg.exceptions import AtombergAuthError, AtombergConnectionError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
23
|
+
"""Generate a PKCE (code_verifier, code_challenge) tuple using S256."""
|
|
24
|
+
verifier = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8").rstrip("=")
|
|
25
|
+
challenge_bytes = hashlib.sha256(verifier.encode("utf-8")).digest()
|
|
26
|
+
challenge = base64.urlsafe_b64encode(challenge_bytes).decode("utf-8").rstrip("=")
|
|
27
|
+
return verifier, challenge
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CognitoOAuthHandler:
|
|
31
|
+
"""Manages OAuth2 authorization code flow with AWS Cognito Hosted UI.
|
|
32
|
+
|
|
33
|
+
Supports third-party identity providers such as Google Sign-In and Apple Sign-In
|
|
34
|
+
federated via auth.atomberg-iot.com.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
client_id: str = COGNITO_CLIENT_ID,
|
|
40
|
+
redirect_uri: str = COGNITO_REDIRECT_URI,
|
|
41
|
+
refresh_token: str | None = None,
|
|
42
|
+
session: aiohttp.ClientSession | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initialize the Cognito OAuth handler."""
|
|
45
|
+
self._client_id = client_id
|
|
46
|
+
self._redirect_uri = redirect_uri
|
|
47
|
+
self._refresh_token = refresh_token
|
|
48
|
+
self._id_token: str | None = None
|
|
49
|
+
self._access_token: str | None = None
|
|
50
|
+
self._expires_at: float = 0.0
|
|
51
|
+
self._session = session
|
|
52
|
+
|
|
53
|
+
# Active PKCE verifier for pending flow
|
|
54
|
+
self._pending_verifier: str | None = None
|
|
55
|
+
self._pending_state: str | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def refresh_token(self) -> str | None:
|
|
59
|
+
"""Return the Cognito refresh token."""
|
|
60
|
+
return self._refresh_token
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def id_token(self) -> str | None:
|
|
64
|
+
"""Return the Cognito ID token used for API requests."""
|
|
65
|
+
return self._id_token
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def access_token(self) -> str | None:
|
|
69
|
+
"""Return the Cognito Access token."""
|
|
70
|
+
return self._access_token
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_authenticated(self) -> bool:
|
|
74
|
+
"""Check if an access token is cached and not expired."""
|
|
75
|
+
return self._access_token is not None and time.time() < (self._expires_at - 60)
|
|
76
|
+
|
|
77
|
+
def get_authorization_url(
|
|
78
|
+
self,
|
|
79
|
+
redirect_uri: str | None = None,
|
|
80
|
+
identity_provider: str | None = None,
|
|
81
|
+
scope: str = "aws.cognito.signin.user.admin openid email",
|
|
82
|
+
) -> tuple[str, str, str]:
|
|
83
|
+
"""Generate the Cognito Hosted UI authorization URL with PKCE.
|
|
84
|
+
|
|
85
|
+
Returns (auth_url, state, code_verifier).
|
|
86
|
+
"""
|
|
87
|
+
if redirect_uri:
|
|
88
|
+
self._redirect_uri = redirect_uri
|
|
89
|
+
|
|
90
|
+
verifier, challenge = generate_pkce_pair()
|
|
91
|
+
state = secrets.token_urlsafe(16)
|
|
92
|
+
self._pending_verifier = verifier
|
|
93
|
+
self._pending_state = state
|
|
94
|
+
|
|
95
|
+
params: dict[str, str] = {
|
|
96
|
+
"client_id": self._client_id,
|
|
97
|
+
"response_type": "code",
|
|
98
|
+
"scope": scope,
|
|
99
|
+
"redirect_uri": self._redirect_uri,
|
|
100
|
+
"state": state,
|
|
101
|
+
"code_challenge": challenge,
|
|
102
|
+
"code_challenge_method": "S256",
|
|
103
|
+
}
|
|
104
|
+
if identity_provider:
|
|
105
|
+
params["identity_provider"] = identity_provider
|
|
106
|
+
|
|
107
|
+
url = f"{COGNITO_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
|
108
|
+
return url, state, verifier
|
|
109
|
+
|
|
110
|
+
async def exchange_code_for_tokens(
|
|
111
|
+
self,
|
|
112
|
+
code: str,
|
|
113
|
+
code_verifier: str | None = None,
|
|
114
|
+
redirect_uri: str | None = None,
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Exchange authorization code for Cognito tokens."""
|
|
117
|
+
verifier = code_verifier or self._pending_verifier
|
|
118
|
+
if not verifier:
|
|
119
|
+
raise AtombergAuthError("No PKCE code_verifier provided or found in pending state")
|
|
120
|
+
|
|
121
|
+
r_uri = redirect_uri or self._redirect_uri
|
|
122
|
+
payload = {
|
|
123
|
+
"grant_type": "authorization_code",
|
|
124
|
+
"client_id": self._client_id,
|
|
125
|
+
"code": code,
|
|
126
|
+
"redirect_uri": r_uri,
|
|
127
|
+
"code_verifier": verifier,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return await self._post_token_request(payload)
|
|
131
|
+
|
|
132
|
+
async def refresh_tokens(self) -> dict[str, Any]:
|
|
133
|
+
"""Refresh tokens using the stored refresh_token."""
|
|
134
|
+
if not self._refresh_token:
|
|
135
|
+
raise AtombergAuthError("No refresh token available to refresh Cognito session")
|
|
136
|
+
|
|
137
|
+
payload = {
|
|
138
|
+
"grant_type": "refresh_token",
|
|
139
|
+
"client_id": self._client_id,
|
|
140
|
+
"refresh_token": self._refresh_token,
|
|
141
|
+
}
|
|
142
|
+
return await self._post_token_request(payload)
|
|
143
|
+
|
|
144
|
+
async def _post_token_request(self, payload: dict[str, str]) -> dict[str, Any]:
|
|
145
|
+
"""Post request to Cognito token endpoint."""
|
|
146
|
+
close_session = False
|
|
147
|
+
session = self._session
|
|
148
|
+
if session is None:
|
|
149
|
+
session = aiohttp.ClientSession()
|
|
150
|
+
close_session = True
|
|
151
|
+
|
|
152
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
153
|
+
try:
|
|
154
|
+
async with session.post(
|
|
155
|
+
COGNITO_TOKEN_URL,
|
|
156
|
+
data=payload,
|
|
157
|
+
headers=headers,
|
|
158
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
159
|
+
) as resp:
|
|
160
|
+
data: dict[str, Any] = await resp.json(content_type=None)
|
|
161
|
+
if resp.status != 200:
|
|
162
|
+
err = data.get("error_description") or data.get("error") or f"HTTP {resp.status}"
|
|
163
|
+
raise AtombergAuthError(f"Cognito token request failed: {err}")
|
|
164
|
+
|
|
165
|
+
self._access_token = data.get("access_token")
|
|
166
|
+
self._id_token = data.get("id_token")
|
|
167
|
+
if "refresh_token" in data:
|
|
168
|
+
self._refresh_token = data["refresh_token"]
|
|
169
|
+
|
|
170
|
+
expires_in = data.get("expires_in", 3600)
|
|
171
|
+
self._expires_at = time.time() + float(expires_in)
|
|
172
|
+
return data
|
|
173
|
+
except aiohttp.ClientError as err:
|
|
174
|
+
raise AtombergConnectionError(f"Network error communicating with Cognito: {err}") from err
|
|
175
|
+
finally:
|
|
176
|
+
if close_session:
|
|
177
|
+
await session.close()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Developer API token authentication handler."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from pyatomberg.const import DEVELOPER_API_BASE
|
|
9
|
+
from pyatomberg.exceptions import AtombergAuthError, AtombergConnectionError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeveloperTokenHandler:
|
|
13
|
+
"""Manages authentication using Atomberg Developer Refresh Tokens.
|
|
14
|
+
|
|
15
|
+
Allows credential-free operation for fans by exchanging the refresh token
|
|
16
|
+
obtained from the Atomberg mobile app for short-lived bearer access tokens.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
refresh_token: str,
|
|
22
|
+
api_key: str | None = None,
|
|
23
|
+
session: aiohttp.ClientSession | None = None,
|
|
24
|
+
base_url: str = DEVELOPER_API_BASE,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Initialize the developer token handler."""
|
|
27
|
+
if not refresh_token or not refresh_token.strip():
|
|
28
|
+
raise AtombergAuthError("Developer refresh token cannot be empty")
|
|
29
|
+
|
|
30
|
+
self._refresh_token = refresh_token.strip()
|
|
31
|
+
self._api_key = api_key.strip() if api_key else None
|
|
32
|
+
self._session = session
|
|
33
|
+
self._base_url = base_url.rstrip("/")
|
|
34
|
+
self._access_token: str | None = None
|
|
35
|
+
self._expires_at: float = 0.0
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def refresh_token(self) -> str:
|
|
39
|
+
"""Return the configured refresh token."""
|
|
40
|
+
return self._refresh_token
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def api_key(self) -> str | None:
|
|
44
|
+
"""Return the configured API key, if any."""
|
|
45
|
+
return self._api_key
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def has_valid_access_token(self) -> bool:
|
|
49
|
+
"""Check if cached access token is valid (with 60s safety buffer)."""
|
|
50
|
+
return self._access_token is not None and time.time() < (self._expires_at - 60)
|
|
51
|
+
|
|
52
|
+
async def get_access_token(self, force_refresh: bool = False) -> str:
|
|
53
|
+
"""Retrieve a valid access token, refreshing if necessary."""
|
|
54
|
+
if not force_refresh and self.has_valid_access_token:
|
|
55
|
+
assert self._access_token is not None
|
|
56
|
+
return self._access_token
|
|
57
|
+
|
|
58
|
+
await self.refresh_access_token()
|
|
59
|
+
assert self._access_token is not None
|
|
60
|
+
return self._access_token
|
|
61
|
+
|
|
62
|
+
async def refresh_access_token(self) -> str:
|
|
63
|
+
"""Exchange developer refresh token for a new access token."""
|
|
64
|
+
url = f"{self._base_url}/get_access_token"
|
|
65
|
+
headers: dict[str, str] = {
|
|
66
|
+
"Authorization": f"Bearer {self._refresh_token}",
|
|
67
|
+
}
|
|
68
|
+
if self._api_key:
|
|
69
|
+
headers["x-api-key"] = self._api_key
|
|
70
|
+
|
|
71
|
+
close_session = False
|
|
72
|
+
session = self._session
|
|
73
|
+
if session is None:
|
|
74
|
+
session = aiohttp.ClientSession()
|
|
75
|
+
close_session = True
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
# First attempt standard GET as documented in Developer Portal v2
|
|
79
|
+
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
|
80
|
+
if resp.status == 405:
|
|
81
|
+
# Fallback to POST for v1 compatibility
|
|
82
|
+
async with session.post(
|
|
83
|
+
url,
|
|
84
|
+
json={"refresh_token": self._refresh_token},
|
|
85
|
+
headers=headers,
|
|
86
|
+
timeout=aiohttp.ClientTimeout(total=15),
|
|
87
|
+
) as post_resp:
|
|
88
|
+
return await self._parse_token_response(post_resp)
|
|
89
|
+
return await self._parse_token_response(resp)
|
|
90
|
+
except aiohttp.ClientError as err:
|
|
91
|
+
raise AtombergConnectionError(f"Network error refreshing access token: {err}") from err
|
|
92
|
+
finally:
|
|
93
|
+
if close_session:
|
|
94
|
+
await session.close()
|
|
95
|
+
|
|
96
|
+
async def _parse_token_response(self, resp: aiohttp.ClientResponse) -> str:
|
|
97
|
+
"""Parse access token JSON response from developer endpoint."""
|
|
98
|
+
data: dict[str, Any] = await resp.json(content_type=None)
|
|
99
|
+
if resp.status != 200 or not data.get("status", True):
|
|
100
|
+
error_msg = data.get("message") or data.get("error") or f"HTTP {resp.status}"
|
|
101
|
+
raise AtombergAuthError(f"Failed to refresh developer token: {error_msg}")
|
|
102
|
+
|
|
103
|
+
token = data.get("access_token") or data.get("token") or data.get("data", {}).get("access_token")
|
|
104
|
+
if not token:
|
|
105
|
+
raise AtombergAuthError("Response did not contain an access_token")
|
|
106
|
+
|
|
107
|
+
expires_in = data.get("expires_in", 3600)
|
|
108
|
+
self._access_token = str(token)
|
|
109
|
+
self._expires_at = time.time() + float(expires_in)
|
|
110
|
+
return self._access_token
|
|
111
|
+
|
|
112
|
+
async def get_auth_headers(self) -> dict[str, str]:
|
|
113
|
+
"""Return HTTP authorization headers with a valid Bearer token and API key."""
|
|
114
|
+
token = await self.get_access_token()
|
|
115
|
+
headers: dict[str, str] = {
|
|
116
|
+
"Authorization": f"Bearer {token}",
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
}
|
|
119
|
+
if self._api_key:
|
|
120
|
+
headers["x-api-key"] = self._api_key
|
|
121
|
+
return headers
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Interactive Google OAuth setup script for Atomberg accounts.
|
|
2
|
+
|
|
3
|
+
Enables users to sign in with Google in their browser and automatically
|
|
4
|
+
exchanges the resulting authorization code for permanent Cognito refresh and ID tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import Any
|
|
11
|
+
import urllib.parse
|
|
12
|
+
|
|
13
|
+
from pyatomberg.auth.cognito import CognitoOAuthHandler
|
|
14
|
+
from pyatomberg.const import COGNITO_CLIENT_ID, COGNITO_REDIRECT_URI
|
|
15
|
+
from pyatomberg.lock.cloud_sync import AtombergCloudSync
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def run_interactive_login() -> tuple[dict[str, str], list[Any]]:
|
|
19
|
+
"""Run interactive CLI Google login and return tokens and discovered locks."""
|
|
20
|
+
handler = CognitoOAuthHandler(
|
|
21
|
+
client_id=COGNITO_CLIENT_ID,
|
|
22
|
+
redirect_uri=COGNITO_REDIRECT_URI,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
auth_url, state, verifier = handler.get_authorization_url(identity_provider=None)
|
|
26
|
+
|
|
27
|
+
print("\n" + "=" * 70)
|
|
28
|
+
print(" ATOMBERG UNIVERSAL ACCOUNT LOGIN (EMAIL / GOOGLE / APPLE / FACEBOOK)")
|
|
29
|
+
print("=" * 70)
|
|
30
|
+
print("\n1. Open the following URL in your web browser:\n")
|
|
31
|
+
print(auth_url)
|
|
32
|
+
print("\n2. Sign in with your Atomberg email & password, Google, Apple, or Facebook.")
|
|
33
|
+
print("3. After signing in, your browser will redirect to https://app.atomberg-iot.com/?code=...")
|
|
34
|
+
print("4. Copy the entire URL (or just the code value) from your browser address bar.")
|
|
35
|
+
print("=" * 70 + "\n")
|
|
36
|
+
|
|
37
|
+
user_response = input("Paste the redirected URL or authorization code here: ").strip()
|
|
38
|
+
if not user_response:
|
|
39
|
+
print("[!] No input provided. Aborting.")
|
|
40
|
+
return {}, []
|
|
41
|
+
|
|
42
|
+
# Extract authorization code
|
|
43
|
+
auth_code = user_response
|
|
44
|
+
if "code=" in user_response:
|
|
45
|
+
parsed = urllib.parse.urlparse(user_response)
|
|
46
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
47
|
+
code_list = params.get("code")
|
|
48
|
+
if code_list:
|
|
49
|
+
auth_code = code_list[0]
|
|
50
|
+
else:
|
|
51
|
+
# Maybe in fragment or raw string
|
|
52
|
+
for part in user_response.split("&"):
|
|
53
|
+
if part.startswith("code=") or "code=" in part:
|
|
54
|
+
auth_code = part.split("code=")[-1]
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
print(f"\n[*] Exchanging authorization code for Cognito tokens...")
|
|
58
|
+
tokens = await handler.exchange_code_for_tokens(
|
|
59
|
+
code=auth_code,
|
|
60
|
+
code_verifier=verifier,
|
|
61
|
+
redirect_uri=COGNITO_REDIRECT_URI,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
access_token = tokens.get("access_token", "")
|
|
65
|
+
id_token = tokens.get("id_token", "")
|
|
66
|
+
refresh_token = tokens.get("refresh_token", "")
|
|
67
|
+
|
|
68
|
+
print("\n[+] Authentication successful!")
|
|
69
|
+
print(f" Refresh Token (first 20 chars): {refresh_token[:20]}...")
|
|
70
|
+
print(f" Access Token (first 20 chars): {access_token[:20]}...")
|
|
71
|
+
print(f" ID Token (first 20 chars): {id_token[:20]}...")
|
|
72
|
+
|
|
73
|
+
print("\n[*] Querying Atomberg cloud account (server.atomberg-iot.com/app_open) for smart locks...")
|
|
74
|
+
sync_client = AtombergCloudSync(access_token=access_token)
|
|
75
|
+
try:
|
|
76
|
+
locks = await sync_client.fetch_account_locks()
|
|
77
|
+
if not locks:
|
|
78
|
+
print("[-] No smart locks found in this Atomberg account.")
|
|
79
|
+
else:
|
|
80
|
+
print(f"[+] Discovered {len(locks)} smart lock(s) registered in your account:\n")
|
|
81
|
+
for idx, lock in enumerate(locks, 1):
|
|
82
|
+
print(f" Lock #{idx}:")
|
|
83
|
+
print(f" Name: {lock.name or 'Atomberg Smart Lock'}")
|
|
84
|
+
print(f" Model: {lock.model or 'smart_lock'}")
|
|
85
|
+
print(f" MAC Address: {lock.mac_address}")
|
|
86
|
+
print(f" Master Key: {lock.master_key_hex}")
|
|
87
|
+
print(f" Salt: {lock.salt_hex}")
|
|
88
|
+
return tokens, locks
|
|
89
|
+
except Exception as err:
|
|
90
|
+
print(f"[!] Error syncing account locks: {err}")
|
|
91
|
+
return tokens, []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main() -> None:
|
|
95
|
+
"""Entry point for CLI command."""
|
|
96
|
+
asyncio.run(run_interactive_login())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|