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 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,6 @@
1
+ """Authentication handlers for pyatomberg."""
2
+
3
+ from pyatomberg.auth.cognito import CognitoOAuthHandler
4
+ from pyatomberg.auth.developer import DeveloperTokenHandler
5
+
6
+ __all__ = ["DeveloperTokenHandler", "CognitoOAuthHandler"]
@@ -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()