aioamazondevices 6.5.1__py3-none-any.whl → 11.0.2__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.
@@ -0,0 +1,422 @@
1
+ """aioamazondevices HTTP wrapper module."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import secrets
6
+ from collections.abc import Callable, Coroutine
7
+ from http import HTTPMethod, HTTPStatus
8
+ from http.cookies import Morsel
9
+ from typing import Any, cast
10
+
11
+ import orjson
12
+ from aiohttp import (
13
+ ClientConnectorError,
14
+ ClientResponse,
15
+ ClientSession,
16
+ ContentTypeError,
17
+ )
18
+ from bs4 import BeautifulSoup
19
+ from langcodes import Language, standardize_tag
20
+ from yarl import URL
21
+
22
+ from . import __version__
23
+ from .const.http import (
24
+ AMAZON_APP_BUNDLE_ID,
25
+ AMAZON_APP_ID,
26
+ AMAZON_APP_NAME,
27
+ AMAZON_APP_VERSION,
28
+ AMAZON_CLIENT_OS,
29
+ AMAZON_DEVICE_SOFTWARE_VERSION,
30
+ ARRAY_WRAPPER,
31
+ CSRF_COOKIE,
32
+ DEFAULT_HEADERS,
33
+ HTTP_ERROR_199,
34
+ HTTP_ERROR_299,
35
+ REFRESH_ACCESS_TOKEN,
36
+ REFRESH_AUTH_COOKIES,
37
+ REQUEST_AGENT,
38
+ URI_SIGNIN,
39
+ )
40
+ from .exceptions import (
41
+ CannotAuthenticate,
42
+ CannotConnect,
43
+ CannotRetrieveData,
44
+ )
45
+ from .utils import _LOGGER, scrub_fields
46
+
47
+
48
+ class AmazonSessionStateData:
49
+ """Amazon session state data class."""
50
+
51
+ def __init__(
52
+ self,
53
+ domain: str,
54
+ login_email: str,
55
+ login_password: str,
56
+ login_data: dict[str, Any] | None = None,
57
+ ) -> None:
58
+ """Init state class."""
59
+ self._login_email: str = login_email
60
+ self._login_password: str = login_password
61
+ self._login_stored_data: dict[str, Any] = login_data or {}
62
+ self.country_specific_data(domain)
63
+ self._account_customer_id: str | None = None
64
+
65
+ @property
66
+ def country_code(self) -> str:
67
+ """Return country code."""
68
+ return self._country_code
69
+
70
+ @property
71
+ def domain(self) -> str:
72
+ """Return domain."""
73
+ return self._domain
74
+
75
+ @property
76
+ def language(self) -> str:
77
+ """Return language."""
78
+ return self._language
79
+
80
+ @property
81
+ def login_email(self) -> str:
82
+ """Return login email."""
83
+ return self._login_email
84
+
85
+ @property
86
+ def login_password(self) -> str:
87
+ """Return login password."""
88
+ return self._login_password
89
+
90
+ @property
91
+ def login_stored_data(self) -> dict[str, Any]:
92
+ """Return login stored data."""
93
+ return self._login_stored_data
94
+
95
+ @login_stored_data.setter
96
+ def login_stored_data(self, data: dict[str, Any]) -> None:
97
+ """Set login stored data."""
98
+ self._login_stored_data = data
99
+
100
+ @property
101
+ def account_customer_id(self) -> str | None:
102
+ """Return account customer id."""
103
+ return self._account_customer_id
104
+
105
+ @account_customer_id.setter
106
+ def account_customer_id(self, customer_id: str | None) -> None:
107
+ """Set account customer id."""
108
+ self._account_customer_id = customer_id
109
+
110
+ def country_specific_data(self, domain: str) -> None:
111
+ """Set country specific data."""
112
+ # Force lower case
113
+ domain = domain.replace("https://www.amazon.", "").lower()
114
+ country_code = domain.split(".")[-1] if domain != "com" else "us"
115
+
116
+ lang_object = Language.make(territory=country_code.upper())
117
+ lang_maximized = lang_object.maximize()
118
+
119
+ self._country_code: str = country_code
120
+ self._domain: str = domain
121
+ language = f"{lang_maximized.language}-{lang_maximized.territory}"
122
+ self._language: str = standardize_tag(language)
123
+
124
+ _LOGGER.debug(
125
+ "Initialize country <%s>: domain <amazon.%s>, language <%s>",
126
+ country_code.upper(),
127
+ self._domain,
128
+ self._language,
129
+ )
130
+
131
+
132
+ class AmazonHttpWrapper:
133
+ """Amazon HTTP wrapper class."""
134
+
135
+ def __init__(
136
+ self,
137
+ client_session: ClientSession,
138
+ session_state_data: AmazonSessionStateData,
139
+ save_to_file: Callable[[str | dict, str, str], Coroutine[Any, Any, None]]
140
+ | None = None,
141
+ ) -> None:
142
+ """Initialize HTTP wrapper."""
143
+ self._session = client_session
144
+ self._session_state_data: AmazonSessionStateData = session_state_data
145
+ self._save_to_file = save_to_file
146
+
147
+ self._csrf_cookie: str | None = None
148
+ self._cookies: dict[str, str] = self._build_init_cookies()
149
+
150
+ @property
151
+ def cookies(self) -> dict[str, str]:
152
+ """Return the current cookies."""
153
+ return self._cookies
154
+
155
+ def _load_website_cookies(self, language: str) -> dict[str, str]:
156
+ """Get website cookies, if available."""
157
+ if not self._session_state_data.login_stored_data:
158
+ return {}
159
+
160
+ website_cookies: dict[str, Any] = self._session_state_data.login_stored_data[
161
+ "website_cookies"
162
+ ]
163
+ website_cookies.update(
164
+ {
165
+ "session-token": self._session_state_data.login_stored_data[
166
+ "store_authentication_cookie"
167
+ ]["cookie"]
168
+ }
169
+ )
170
+ website_cookies.update({"lc-acbit": language})
171
+
172
+ return website_cookies
173
+
174
+ def _build_init_cookies(self) -> dict[str, str]:
175
+ """Build initial cookies to prevent captcha in most cases."""
176
+ token_bytes = secrets.token_bytes(313)
177
+ frc = base64.b64encode(token_bytes).decode("ascii").rstrip("=")
178
+
179
+ map_md_dict = {
180
+ "device_user_dictionary": [],
181
+ "device_registration_data": {
182
+ "software_version": AMAZON_DEVICE_SOFTWARE_VERSION,
183
+ },
184
+ "app_identifier": {
185
+ "app_version": AMAZON_APP_VERSION,
186
+ "bundle_id": AMAZON_APP_BUNDLE_ID,
187
+ },
188
+ }
189
+ map_md_str = orjson.dumps(map_md_dict).decode("utf-8")
190
+ map_md = base64.b64encode(map_md_str.encode()).decode().rstrip("=")
191
+
192
+ return {"amzn-app-id": AMAZON_APP_ID, "frc": frc, "map-md": map_md}
193
+
194
+ async def _ignore_ap_signin_error(self, response: ClientResponse) -> bool:
195
+ """Return true if error is due to signin endpoint."""
196
+ # Endpoint URI_SIGNIN replies with error 404
197
+ # but reports the needed parameters anyway
198
+ if history := response.history:
199
+ return (
200
+ response.status == HTTPStatus.NOT_FOUND
201
+ and URI_SIGNIN in history[0].request_info.url.path
202
+ )
203
+ return False
204
+
205
+ async def set_session_state_data(
206
+ self, session_state_data: AmazonSessionStateData
207
+ ) -> None:
208
+ """Set the current session state data."""
209
+ self._session_state_data = session_state_data
210
+
211
+ async def http_phrase_error(self, error: int) -> str:
212
+ """Convert numeric error in human phrase."""
213
+ if error == HTTP_ERROR_199:
214
+ return "Miscellaneous Warning"
215
+
216
+ if error == HTTP_ERROR_299:
217
+ return "Miscellaneous Persistent Warning"
218
+
219
+ return HTTPStatus(error).phrase
220
+
221
+ async def refresh_data(self, data_type: str) -> tuple[bool, dict]:
222
+ """Refresh data."""
223
+ if not self._session_state_data.login_stored_data:
224
+ _LOGGER.debug("No login data available, cannot refresh")
225
+ return False, {}
226
+
227
+ data = {
228
+ "app_name": AMAZON_APP_NAME,
229
+ "app_version": AMAZON_APP_VERSION,
230
+ "di.sdk.version": "6.12.4",
231
+ "source_token": self._session_state_data.login_stored_data["refresh_token"],
232
+ "package_name": AMAZON_APP_BUNDLE_ID,
233
+ "di.hw.version": "iPhone",
234
+ "platform": "iOS",
235
+ "requested_token_type": data_type,
236
+ "source_token_type": "refresh_token",
237
+ "di.os.name": "iOS",
238
+ "di.os.version": AMAZON_CLIENT_OS,
239
+ "current_version": "6.12.4",
240
+ "previous_version": "6.12.4",
241
+ "domain": f"www.amazon.{self._session_state_data.domain}",
242
+ }
243
+
244
+ _, raw_resp = await self.session_request(
245
+ method=HTTPMethod.POST,
246
+ url="https://api.amazon.com/auth/token",
247
+ input_data=data,
248
+ json_data=False,
249
+ )
250
+ _LOGGER.debug(
251
+ "Refresh data response %s with payload %s",
252
+ raw_resp.status,
253
+ orjson.dumps(data),
254
+ )
255
+
256
+ if raw_resp.status != HTTPStatus.OK:
257
+ _LOGGER.debug("Failed to refresh data")
258
+ return False, {}
259
+
260
+ json_response = await self.response_to_json(raw_resp, data_type)
261
+
262
+ if data_type == REFRESH_ACCESS_TOKEN and (
263
+ new_token := json_response.get(REFRESH_ACCESS_TOKEN)
264
+ ):
265
+ self._session_state_data.login_stored_data[REFRESH_ACCESS_TOKEN] = new_token
266
+ return True, json_response
267
+
268
+ if data_type == REFRESH_AUTH_COOKIES:
269
+ return True, json_response
270
+
271
+ _LOGGER.debug("Unexpected refresh data response")
272
+ return False, {}
273
+
274
+ async def session_request(
275
+ self,
276
+ method: str,
277
+ url: str,
278
+ input_data: dict[str, Any] | list[dict[str, Any]] | None = None,
279
+ json_data: bool = False,
280
+ extended_headers: dict[str, str] | None = None,
281
+ ) -> tuple[BeautifulSoup, ClientResponse]:
282
+ """Return request response context data."""
283
+ _LOGGER.debug(
284
+ "%s request: %s with payload %s [json=%s]",
285
+ method,
286
+ url,
287
+ scrub_fields(input_data) if input_data else None,
288
+ json_data,
289
+ )
290
+
291
+ headers = DEFAULT_HEADERS.copy()
292
+ headers.update({"User-Agent": REQUEST_AGENT["Browser"]})
293
+ headers.update({"Accept-Language": self._session_state_data.language})
294
+ headers.update({"x-amzn-client": "github.com/chemelli74/aioamazondevices"})
295
+ headers.update({"x-amzn-build-version": __version__})
296
+
297
+ if extended_headers:
298
+ _LOGGER.debug("Adding to headers: %s", extended_headers)
299
+ headers.update(extended_headers)
300
+
301
+ if self._csrf_cookie:
302
+ csrf = {CSRF_COOKIE: self._csrf_cookie}
303
+ _LOGGER.debug("Adding to headers: %s", csrf)
304
+ headers.update(csrf)
305
+
306
+ if json_data:
307
+ json_header = {"Content-Type": "application/json; charset=utf-8"}
308
+ _LOGGER.debug("Adding to headers: %s", json_header)
309
+ headers.update(json_header)
310
+
311
+ _cookies = (
312
+ self._load_website_cookies(self._session_state_data.language)
313
+ if self._session_state_data.login_stored_data
314
+ else self._cookies
315
+ )
316
+ self._session.cookie_jar.update_cookies(
317
+ _cookies, URL(f"amazon.{self._session_state_data.domain}")
318
+ )
319
+
320
+ resp: ClientResponse | None = None
321
+ for delay in [0, 1, 2, 5, 8, 12, 21]:
322
+ if delay:
323
+ _LOGGER.info(
324
+ "Sleeping for %s seconds before retrying API call to %s", delay, url
325
+ )
326
+ await asyncio.sleep(delay)
327
+
328
+ try:
329
+ resp = await self._session.request(
330
+ method,
331
+ URL(url, encoded=True),
332
+ data=input_data if not json_data else orjson.dumps(input_data),
333
+ headers=headers,
334
+ )
335
+
336
+ except (TimeoutError, ClientConnectorError) as exc:
337
+ _LOGGER.warning("Connection error to %s: %s", url, repr(exc))
338
+ raise CannotConnect(f"Connection error during {method}") from exc
339
+
340
+ # Retry with a delay only for specific HTTP status
341
+ # that can benefits of a back-off
342
+ if resp.status not in [
343
+ HTTPStatus.INTERNAL_SERVER_ERROR,
344
+ HTTPStatus.SERVICE_UNAVAILABLE,
345
+ HTTPStatus.TOO_MANY_REQUESTS,
346
+ ]:
347
+ break
348
+
349
+ if resp is None:
350
+ _LOGGER.error("No response received from %s", url)
351
+ raise CannotConnect(f"No response received from {url}")
352
+
353
+ if csrf := resp.cookies.get(CSRF_COOKIE, Morsel()).value:
354
+ self._csrf_cookie = csrf
355
+ _LOGGER.debug("CSRF cookie value: <%s> [%s]", self._csrf_cookie, url)
356
+
357
+ content_type: str = resp.headers.get("Content-Type", "")
358
+ _LOGGER.debug(
359
+ "Response for url %s :\nstatus : %s \
360
+ \ncontent type: %s ",
361
+ url,
362
+ resp.status,
363
+ content_type,
364
+ )
365
+
366
+ if resp.status != HTTPStatus.OK:
367
+ if resp.status in [
368
+ HTTPStatus.FORBIDDEN,
369
+ HTTPStatus.PROXY_AUTHENTICATION_REQUIRED,
370
+ HTTPStatus.UNAUTHORIZED,
371
+ ]:
372
+ raise CannotAuthenticate(await self.http_phrase_error(resp.status))
373
+ if not await self._ignore_ap_signin_error(resp):
374
+ _LOGGER.debug("Error response content: %s", await resp.text())
375
+ raise CannotRetrieveData(
376
+ f"Request failed: {await self.http_phrase_error(resp.status)}"
377
+ )
378
+
379
+ raw_content = await resp.read()
380
+
381
+ if self._save_to_file:
382
+ await self._save_to_file(
383
+ raw_content.decode("utf-8"),
384
+ url,
385
+ content_type,
386
+ )
387
+
388
+ return BeautifulSoup(raw_content or "", "html.parser"), resp
389
+
390
+ async def response_to_json(
391
+ self, raw_resp: ClientResponse, description: str | None = None
392
+ ) -> dict[str, Any]:
393
+ """Convert response to JSON, if possible."""
394
+ try:
395
+ data = await raw_resp.json(loads=orjson.loads)
396
+ if not data:
397
+ _LOGGER.warning("Empty JSON data received")
398
+ data = {}
399
+ if isinstance(data, list):
400
+ # if anonymous array is returned wrap it inside
401
+ # generated key to convert list to dict
402
+ data = {ARRAY_WRAPPER: data}
403
+ if description:
404
+ _LOGGER.debug("JSON '%s' data: %s", description, scrub_fields(data))
405
+ return cast("dict[str, Any]", data)
406
+ except ContentTypeError as exc:
407
+ raise ValueError("Response not in JSON format") from exc
408
+ except orjson.JSONDecodeError as exc:
409
+ raise ValueError("Response with corrupted JSON format") from exc
410
+
411
+ async def clear_cookies(self) -> None:
412
+ """Clear session cookies."""
413
+ self._session.cookie_jar.clear()
414
+ await self.clear_csrf_cookie()
415
+
416
+ async def clear_csrf_cookie(self) -> None:
417
+ """Clear CSRF cookie."""
418
+ self._csrf_cookie = None
419
+
420
+ async def set_cookies(self, cookies: dict[str, str], domain_url: URL) -> None:
421
+ """Set session cookies."""
422
+ self._session.cookie_jar.update_cookies(cookies, domain_url)
@@ -0,0 +1 @@
1
+ """aioamazondevices implementation package."""
@@ -0,0 +1,56 @@
1
+ """Module to handle Alexa do not disturb setting."""
2
+
3
+ from http import HTTPMethod
4
+
5
+ from aioamazondevices.const.http import URI_DND_STATUS_ALL, URI_DND_STATUS_DEVICE
6
+ from aioamazondevices.http_wrapper import AmazonHttpWrapper, AmazonSessionStateData
7
+ from aioamazondevices.structures import AmazonDevice, AmazonDeviceSensor
8
+
9
+
10
+ class AmazonDnDHandler:
11
+ """Class to handle Alexa Do Not Disturb functionality."""
12
+
13
+ def __init__(
14
+ self,
15
+ http_wrapper: AmazonHttpWrapper,
16
+ session_state_data: AmazonSessionStateData,
17
+ ) -> None:
18
+ """Initialize AmazonDnDHandler class."""
19
+ self._domain = session_state_data.domain
20
+ self._http_wrapper = http_wrapper
21
+
22
+ async def get_do_not_disturb_status(self) -> dict[str, AmazonDeviceSensor]:
23
+ """Get do_not_disturb status for all devices."""
24
+ dnd_status: dict[str, AmazonDeviceSensor] = {}
25
+ _, raw_resp = await self._http_wrapper.session_request(
26
+ method=HTTPMethod.GET,
27
+ url=f"https://alexa.amazon.{self._domain}{URI_DND_STATUS_ALL}",
28
+ )
29
+
30
+ dnd_data = await self._http_wrapper.response_to_json(raw_resp, "dnd")
31
+
32
+ for dnd in dnd_data.get("doNotDisturbDeviceStatusList", {}):
33
+ dnd_status[dnd.get("deviceSerialNumber")] = AmazonDeviceSensor(
34
+ name="dnd",
35
+ value=dnd.get("enabled"),
36
+ error=False,
37
+ error_type=None,
38
+ error_msg=None,
39
+ scale=None,
40
+ )
41
+ return dnd_status
42
+
43
+ async def set_do_not_disturb(self, device: AmazonDevice, enable: bool) -> None:
44
+ """Set do_not_disturb flag."""
45
+ payload = {
46
+ "deviceSerialNumber": device.serial_number,
47
+ "deviceType": device.device_type,
48
+ "enabled": enable,
49
+ }
50
+ url = f"https://alexa.amazon.{self._domain}{URI_DND_STATUS_DEVICE}"
51
+ await self._http_wrapper.session_request(
52
+ method=HTTPMethod.PUT,
53
+ url=url,
54
+ input_data=payload,
55
+ json_data=True,
56
+ )