aioamazondevices 0.13.0__tar.gz → 1.1.0__tar.gz

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.
@@ -1,8 +1,7 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: aioamazondevices
3
- Version: 0.13.0
3
+ Version: 1.1.0
4
4
  Summary: Python library to control Amazon devices
5
- Home-page: https://github.com/chemelli74/aioamazondevices
6
5
  License: Apache-2.0
7
6
  Author: Simone Chemelli
8
7
  Author-email: simone.chemelli@gmail.com
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "aioamazondevices"
3
- version = "0.13.0"
3
+ version = "1.1.0"
4
4
  description = "Python library to control Amazon devices"
5
5
  authors = ["Simone Chemelli <simone.chemelli@gmail.com>"]
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  """aioamazondevices library."""
2
2
 
3
- __version__ = "0.13.0"
3
+ __version__ = "1.1.0"
4
4
 
5
5
 
6
6
  from .api import AmazonDevice, AmazonEchoApi
@@ -14,9 +14,8 @@ from urllib.parse import parse_qs, urlencode
14
14
 
15
15
  import orjson
16
16
  from bs4 import BeautifulSoup, Tag
17
- from httpx import URL, AsyncClient, Auth, Response
17
+ from httpx import URL, AsyncClient, Response
18
18
 
19
- from .auth import Authenticator
20
19
  from .const import (
21
20
  _LOGGER,
22
21
  AMAZON_APP_BUNDLE_ID,
@@ -28,7 +27,7 @@ from .const import (
28
27
  AMAZON_DEVICE_TYPE,
29
28
  DEFAULT_ASSOC_HANDLE,
30
29
  DEFAULT_HEADERS,
31
- DOMAIN_BY_COUNTRY,
30
+ DOMAIN_BY_ISO3166_COUNTRY,
32
31
  HTML_EXTENSION,
33
32
  JSON_EXTENSION,
34
33
  NODE_BLUETOOTH,
@@ -72,7 +71,7 @@ class AmazonEchoApi:
72
71
  # Force country digits as lower case
73
72
  country_code = login_country_code.lower()
74
73
 
75
- locale = DOMAIN_BY_COUNTRY.get(country_code)
74
+ locale = DOMAIN_BY_ISO3166_COUNTRY.get(country_code)
76
75
  domain = locale["domain"] if locale else country_code
77
76
 
78
77
  if locale and (assoc := locale.get("openid.assoc_handle")):
@@ -84,7 +83,6 @@ class AmazonEchoApi:
84
83
  self._login_email = login_email
85
84
  self._login_password = login_password
86
85
  self._domain = domain
87
- self._url = f"https://www.amazon.{domain}"
88
86
  self._cookies = self._build_init_cookies()
89
87
  self._headers = DEFAULT_HEADERS
90
88
  self._save_raw_data = save_raw_data
@@ -99,7 +97,7 @@ class AmazonEchoApi:
99
97
  if not self._login_stored_data:
100
98
  return {}
101
99
 
102
- return cast(dict, self._login_stored_data["website_cookies"])
100
+ return cast("dict", self._login_stored_data["website_cookies"])
103
101
 
104
102
  def _serial_number(self) -> str:
105
103
  """Get or calculate device serial number."""
@@ -110,7 +108,7 @@ class AmazonEchoApi:
110
108
 
111
109
  _LOGGER.debug("Found previous login data, loading serial number")
112
110
  return cast(
113
- str,
111
+ "str",
114
112
  self._login_stored_data["device_info"]["device_serial_number"],
115
113
  )
116
114
 
@@ -208,16 +206,15 @@ class AmazonEchoApi:
208
206
  parsed_url = parse_qs(url.query.decode())
209
207
  return parsed_url["openid.oa2.authorization_code"][0]
210
208
 
211
- def _client_session(self, auth: Auth | None = None) -> None:
212
- """Create httpx ClientSession."""
209
+ def _client_session(self) -> None:
210
+ """Create HTTP client session."""
213
211
  if not hasattr(self, "session") or self.session.is_closed:
214
- _LOGGER.debug("Creating HTTP ClientSession")
212
+ _LOGGER.debug("Creating HTTP session (httpx)")
215
213
  self.session = AsyncClient(
216
214
  base_url=f"https://www.amazon.{self._domain}",
217
215
  headers=DEFAULT_HEADERS,
218
216
  cookies=self._cookies,
219
217
  follow_redirects=True,
220
- auth=auth,
221
218
  )
222
219
 
223
220
  async def _session_request(
@@ -225,17 +222,30 @@ class AmazonEchoApi:
225
222
  method: str,
226
223
  url: str,
227
224
  input_data: dict[str, Any] | None = None,
225
+ json_data: bool = False,
228
226
  ) -> tuple[BeautifulSoup, Response]:
229
227
  """Return request response context data."""
230
- _LOGGER.debug("%s request: %s with payload %s", method, url, input_data)
228
+ _LOGGER.debug(
229
+ "%s request: %s with payload %s [json=%s]",
230
+ method,
231
+ url,
232
+ input_data,
233
+ json_data,
234
+ )
231
235
  resp = await self.session.request(
232
236
  method,
233
237
  url,
234
- data=input_data,
238
+ data=input_data if not json_data else orjson.dumps(input_data),
235
239
  cookies=self._website_cookies,
240
+ headers={"Content-Type": "application/json"} if json_data else None,
236
241
  )
237
242
  content_type: str = resp.headers.get("Content-Type", "")
238
- _LOGGER.debug("Response content type: %s", content_type)
243
+ _LOGGER.debug(
244
+ "Response %s for url %s with content type: %s",
245
+ resp.status_code,
246
+ url,
247
+ content_type,
248
+ )
239
249
 
240
250
  await self._save_to_file(
241
251
  resp.text,
@@ -253,7 +263,7 @@ class AmazonEchoApi:
253
263
  output_path: str = SAVE_PATH,
254
264
  ) -> None:
255
265
  """Save response data to disk."""
256
- if not self._save_raw_data:
266
+ if not self._save_raw_data or not raw_data:
257
267
  return
258
268
 
259
269
  output_dir = Path(output_path)
@@ -328,13 +338,12 @@ class AmazonEchoApi:
328
338
  ],
329
339
  }
330
340
 
331
- headers = {"Content-Type": "application/json"}
332
-
333
341
  register_url = f"https://api.amazon.{self._domain}/auth/register"
334
- resp = await self.session.post(
342
+ _, resp = await self._session_request(
343
+ "POST",
335
344
  register_url,
336
- json=body,
337
- headers=headers,
345
+ input_data=body,
346
+ json_data=True,
338
347
  )
339
348
  resp_json = resp.json()
340
349
 
@@ -409,7 +418,9 @@ class AmazonEchoApi:
409
418
  )
410
419
 
411
420
  if not login_soup.find("input", id="auth-mfa-otpcode"):
412
- _LOGGER.debug('Cannot find "auth-mfa-otpcode" in html source')
421
+ _LOGGER.debug(
422
+ 'Cannot find "auth-mfa-otpcode" in html source [%s]', login_url
423
+ )
413
424
  raise CannotAuthenticate
414
425
 
415
426
  login_method, login_url = self._get_request_from_soup(login_soup)
@@ -463,18 +474,14 @@ class AmazonEchoApi:
463
474
  self._login_email,
464
475
  )
465
476
 
466
- auth = Authenticator.from_dict(
467
- self._login_stored_data,
468
- self._domain,
469
- )
470
- self._client_session(auth)
477
+ self._client_session()
471
478
 
472
479
  return self._login_stored_data
473
480
 
474
481
  async def close(self) -> None:
475
- """Close httpx session."""
482
+ """Close http client session."""
476
483
  if hasattr(self, "session"):
477
- _LOGGER.debug("Closing httpx session")
484
+ _LOGGER.debug("Closing HTTP session (httpx)")
478
485
  await self.session.aclose()
479
486
 
480
487
  async def get_devices_data(
@@ -493,7 +500,7 @@ class AmazonEchoApi:
493
500
 
494
501
  response_data = raw_resp.text
495
502
  _LOGGER.debug("Response data: |%s|", response_data)
496
- json_data = {} if len(raw_resp.content) == 0 else raw_resp.json()
503
+ json_data = {} if len(response_data) == 0 else raw_resp.json()
497
504
 
498
505
  _LOGGER.debug("JSON data: |%s|", json_data)
499
506
 
@@ -6,12 +6,12 @@ _LOGGER = logging.getLogger(__package__)
6
6
 
7
7
  DEFAULT_ASSOC_HANDLE = "amzn_dp_project_dee_ios"
8
8
 
9
- DOMAIN_BY_COUNTRY = {
9
+ DOMAIN_BY_ISO3166_COUNTRY = {
10
10
  "us": {
11
11
  "domain": "com",
12
12
  "openid.assoc_handle": DEFAULT_ASSOC_HANDLE,
13
13
  },
14
- "uk": {
14
+ "gb": {
15
15
  "domain": "co.uk",
16
16
  },
17
17
  "au": {
@@ -1,315 +0,0 @@
1
- """Custom authentication module for httpx."""
2
-
3
- import base64
4
- from collections.abc import Generator
5
- from datetime import UTC, datetime, timedelta
6
- from typing import (
7
- Any,
8
- cast,
9
- )
10
-
11
- import httpx
12
- from httpx import Cookies
13
- from pyasn1.codec.der import decoder
14
- from pyasn1.type import namedtype, univ
15
- from rsa import PrivateKey, pkcs1
16
-
17
- from .const import _LOGGER, AMAZON_APP_NAME, AMAZON_APP_VERSION
18
- from .exceptions import (
19
- AuthFlowError,
20
- AuthMissingAccessToken,
21
- AuthMissingRefreshToken,
22
- AuthMissingSigningData,
23
- AuthMissingTimestamp,
24
- AuthMissingWebsiteCookies,
25
- )
26
-
27
-
28
- def base64_der_to_pkcs1(base64_key: str) -> str:
29
- """Convert DER private key to PEM format."""
30
-
31
- class PrivateKeyAlgorithm(univ.Sequence): # type: ignore[misc]
32
- component_type = namedtype.NamedTypes(
33
- namedtype.NamedType("algorithm", univ.ObjectIdentifier()),
34
- namedtype.NamedType("parameters", univ.Any()),
35
- )
36
-
37
- class PrivateKeyInfo(univ.Sequence): # type: ignore[misc]
38
- component_type = namedtype.NamedTypes(
39
- namedtype.NamedType("version", univ.Integer()),
40
- namedtype.NamedType("pkalgo", PrivateKeyAlgorithm()),
41
- namedtype.NamedType("key", univ.OctetString()),
42
- )
43
-
44
- encoded_key = base64.b64decode(base64_key)
45
- (key_info, _) = decoder.decode(encoded_key, asn1Spec=PrivateKeyInfo())
46
- key_octet_string = key_info.components[2]
47
- key = PrivateKey.load_pkcs1(key_octet_string, format="DER")
48
- return cast(str, key.save_pkcs1().decode("utf-8"))
49
-
50
-
51
- def refresh_access_token(
52
- refresh_token: str,
53
- domain: str,
54
- ) -> dict[str, Any]:
55
- """Refresh an access token."""
56
- body = {
57
- "app_name": AMAZON_APP_NAME,
58
- "app_version": AMAZON_APP_VERSION,
59
- "source_token": refresh_token,
60
- "requested_token_type": "access_token",
61
- "source_token_type": "refresh_token",
62
- }
63
-
64
- resp = httpx.post(f"https://api.amazon.{domain}/auth/token", data=body)
65
- resp.raise_for_status()
66
- resp_dict = resp.json()
67
-
68
- expires_in_sec = int(resp_dict["expires_in"])
69
- expires = (datetime.now(UTC) + timedelta(seconds=expires_in_sec)).timestamp()
70
-
71
- return {"access_token": resp_dict["access_token"], "expires": expires}
72
-
73
-
74
- def refresh_website_cookies(
75
- refresh_token: str,
76
- domain: str,
77
- ) -> dict[str, str]:
78
- """Fetch website cookies for a specific domain."""
79
- url = f"https://www.amazon.{domain}/ap/exchangetoken/cookies"
80
-
81
- body = {
82
- "app_name": AMAZON_APP_NAME,
83
- "app_version": AMAZON_APP_VERSION,
84
- "source_token": refresh_token,
85
- "requested_token_type": "auth_cookies",
86
- "source_token_type": "refresh_token",
87
- "domain": f".amazon.{domain}",
88
- }
89
-
90
- resp = httpx.post(url, data=body)
91
- resp.raise_for_status()
92
- resp_dict = resp.json()
93
-
94
- raw_cookies = resp_dict["response"]["tokens"]["cookies"]
95
- website_cookies = {}
96
- for domain_cookies in raw_cookies:
97
- for cookie in raw_cookies[domain_cookies]:
98
- website_cookies[cookie["Name"]] = cookie["Value"].replace(r'"', r"")
99
-
100
- return website_cookies
101
-
102
-
103
- def user_profile(access_token: str, domain: str) -> dict[str, Any]:
104
- """Return Amazon user profile from Amazon."""
105
- headers = {"Authorization": f"Bearer {access_token}"}
106
-
107
- resp = httpx.get(f"https://api.amazon.{domain}/user/profile", headers=headers)
108
- resp.raise_for_status()
109
- profile: dict[str, Any] = resp.json()
110
-
111
- if "user_id" not in profile:
112
- raise ValueError("Malformed user profile response.")
113
-
114
- return profile
115
-
116
-
117
- def sign_request(
118
- method: str,
119
- path: str,
120
- body: bytes,
121
- adp_token: str,
122
- private_key: str,
123
- ) -> dict[str, str]:
124
- """Create signed headers for http requests."""
125
- date = datetime.now(UTC).isoformat("T") + "Z"
126
- str_body = body.decode("utf-8")
127
-
128
- data = f"{method}\n{path}\n{date}\n{str_body}\n{adp_token}"
129
-
130
- key = PrivateKey.load_pkcs1(private_key.encode("utf-8"))
131
- cipher = pkcs1.sign(data.encode(), key, "SHA-256")
132
- signed_encoded = base64.b64encode(cipher)
133
-
134
- signature = f"{signed_encoded.decode()}:{date}"
135
-
136
- return {
137
- "x-adp-token": adp_token,
138
- "x-adp-alg": "SHA256withRSA:1.0",
139
- "x-adp-signature": signature,
140
- }
141
-
142
-
143
- class Authenticator(httpx.Auth): # type: ignore[misc]
144
- """Amazon Authenticator class."""
145
-
146
- access_token: str | None = None
147
- activation_bytes: str | None = None
148
- adp_token: str | None = None
149
- customer_info: dict[str, Any] | None = None
150
- device_info: dict[str, Any] | None = None
151
- device_private_key: str | None = None
152
- expires: float | None = None
153
- domain: str
154
- refresh_token: str | None = None
155
- store_authentication_cookie: dict[str, Any] | None = None
156
- website_cookies: dict[str, Any] | None = None
157
- requires_request_body: bool = True
158
- _forbid_new_attrs: bool = True
159
- _apply_test_convert: bool = True
160
-
161
- def update_attrs(self, **kwargs: Any) -> None: # noqa: ANN401
162
- """Update attributes from dict."""
163
- for attr, value in kwargs.items():
164
- setattr(self, attr, value)
165
-
166
- @classmethod
167
- def from_dict(
168
- cls,
169
- data: dict[str, Any],
170
- domain: str,
171
- ) -> "Authenticator":
172
- """Instantiate an Authenticator from authentication dictionary."""
173
- auth = cls()
174
-
175
- auth.domain = domain
176
-
177
- if "login_cookies" in data:
178
- auth.website_cookies = data.pop("login_cookies")
179
-
180
- if data["device_private_key"].startswith("MII"):
181
- pk_der = data["device_private_key"]
182
- data["device_private_key"] = base64_der_to_pkcs1(pk_der)
183
-
184
- auth.update_attrs(**data)
185
-
186
- _LOGGER.info("load data from dictionary for domain %s", auth.domain)
187
-
188
- return auth
189
-
190
- def auth_flow(
191
- self,
192
- request: httpx.Request,
193
- ) -> Generator[httpx.Request, httpx.Response, None]:
194
- """Auth flow to be executed on every request by :mod:`httpx`."""
195
- available_modes = self.available_auth_modes
196
-
197
- _LOGGER.debug("Auth flow modes: %s", available_modes)
198
- if "signing" in available_modes:
199
- self._apply_signing_auth_flow(request)
200
- elif "bearer" in available_modes:
201
- self._apply_bearer_auth_flow(request)
202
- else:
203
- message = "signing or bearer auth flow are not available."
204
- _LOGGER.critical(message)
205
- raise AuthFlowError(message)
206
-
207
- yield request
208
-
209
- def _apply_signing_auth_flow(self, request: httpx.Request) -> None:
210
- if self.adp_token is None or self.device_private_key is None:
211
- raise AuthMissingSigningData
212
-
213
- headers = sign_request(
214
- method=request.method,
215
- path=request.url.raw_path.decode(),
216
- body=request.content,
217
- adp_token=self.adp_token,
218
- private_key=self.device_private_key,
219
- )
220
-
221
- request.headers.update(headers)
222
- _LOGGER.info("signing auth flow applied to request")
223
-
224
- def _apply_bearer_auth_flow(self, request: httpx.Request) -> None:
225
- if self.access_token_expired:
226
- self.refresh_access_token()
227
-
228
- if self.access_token is None:
229
- raise AuthMissingAccessToken
230
-
231
- headers = {"Authorization": "Bearer " + self.access_token, "client-id": "0"}
232
- request.headers.update(headers)
233
- _LOGGER.info("bearer auth flow applied to request")
234
-
235
- def _apply_cookies_auth_flow(self, request: httpx.Request) -> None:
236
- if self.website_cookies is None:
237
- raise AuthMissingWebsiteCookies
238
- cookies = self.website_cookies.copy()
239
-
240
- Cookies(cookies).set_cookie_header(request)
241
- _LOGGER.info("cookies auth flow applied to request")
242
-
243
- @property
244
- def available_auth_modes(self) -> list[str]:
245
- """List available authentication modes."""
246
- available_modes = []
247
-
248
- if self.adp_token and self.device_private_key:
249
- available_modes.append("signing")
250
-
251
- if self.access_token:
252
- if self.access_token_expired and not self.refresh_token:
253
- pass
254
- else:
255
- available_modes.append("bearer")
256
-
257
- if self.website_cookies:
258
- available_modes.append("cookies")
259
-
260
- return available_modes
261
-
262
- def refresh_access_token(self, force: bool = False) -> None:
263
- """Refresh access token."""
264
- if force or self.access_token_expired:
265
- if self.refresh_token is None:
266
- message = "No refresh token found. Can't refresh access token."
267
- _LOGGER.critical(message)
268
- raise AuthMissingRefreshToken(message)
269
-
270
- refresh_data = refresh_access_token(
271
- refresh_token=self.refresh_token,
272
- domain=self.domain,
273
- )
274
-
275
- self.update_attrs(**refresh_data)
276
- else:
277
- _LOGGER.info(
278
- "Access Token not expired. No refresh necessary. "
279
- "To force refresh please use force=True",
280
- )
281
-
282
- def set_website_cookies_for_country(self) -> None:
283
- """Set website cookies for country."""
284
- if self.refresh_token is None:
285
- raise AuthMissingRefreshToken
286
-
287
- self.website_cookies = refresh_website_cookies(
288
- self.refresh_token,
289
- self.domain,
290
- )
291
-
292
- def user_profile(self) -> dict[str, Any]:
293
- """Get user profile."""
294
- if self.access_token is None:
295
- raise AuthMissingAccessToken
296
-
297
- return user_profile(access_token=self.access_token, domain=self.domain)
298
-
299
- @property
300
- def access_token_expires(self) -> timedelta:
301
- """Time to access token expiration."""
302
- if self.expires is None:
303
- raise AuthMissingTimestamp
304
- return datetime.fromtimestamp(self.expires, UTC) - datetime.now(
305
- UTC,
306
- )
307
-
308
- @property
309
- def access_token_expired(self) -> bool:
310
- """Return True if access token is expired."""
311
- if self.expires is None:
312
- raise AuthMissingTimestamp
313
- return datetime.fromtimestamp(self.expires, UTC) <= datetime.now(
314
- UTC,
315
- )