aioamazondevices 1.2.0__tar.gz → 1.4.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,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: aioamazondevices
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: Python library to control Amazon devices
5
5
  License: Apache-2.0
6
6
  Author: Simone Chemelli
@@ -15,12 +15,12 @@ Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Dist: aiohttp
19
+ Requires-Dist: babel
18
20
  Requires-Dist: beautifulsoup4
19
21
  Requires-Dist: colorlog
20
- Requires-Dist: httpx
21
22
  Requires-Dist: orjson
22
- Requires-Dist: pyasn1
23
- Requires-Dist: rsa
23
+ Requires-Dist: yarl
24
24
  Project-URL: Bug Tracker, https://github.com/chemelli74/aioamazondevices/issues
25
25
  Project-URL: Changelog, https://github.com/chemelli74/aioamazondevices/blob/main/CHANGELOG.md
26
26
  Project-URL: Repository, https://github.com/chemelli74/aioamazondevices
@@ -82,6 +82,7 @@ The script accept command line arguments or a library_test.json config file:
82
82
  "country": "IT",
83
83
  "email": "<my_address@gmail.com>",
84
84
  "password": "<my_password>",
85
+ "device_name": "Echo Dot Livingroom",
85
86
  "login_data_file": "out/login_data.json",
86
87
  "save_raw_data": "True"
87
88
  }
@@ -54,6 +54,7 @@ The script accept command line arguments or a library_test.json config file:
54
54
  "country": "IT",
55
55
  "email": "<my_address@gmail.com>",
56
56
  "password": "<my_password>",
57
+ "device_name": "Echo Dot Livingroom",
57
58
  "login_data_file": "out/login_data.json",
58
59
  "save_raw_data": "True"
59
60
  }
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "aioamazondevices"
3
- version = "1.2.0"
3
+ version = "1.4.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"
@@ -22,13 +22,13 @@ packages = [
22
22
  "Changelog" = "https://github.com/chemelli74/aioamazondevices/blob/main/CHANGELOG.md"
23
23
 
24
24
  [tool.poetry.dependencies]
25
+ aiohttp = "*"
25
26
  python = "^3.12"
27
+ babel = "*"
26
28
  beautifulsoup4 = "*"
27
29
  colorlog = "*"
28
- httpx = "*"
29
30
  orjson = "*"
30
- pyasn1 = "*"
31
- rsa = "*"
31
+ yarl = "*"
32
32
 
33
33
  [tool.poetry.group.dev.dependencies]
34
34
  pytest = "^8.1"
@@ -1,6 +1,6 @@
1
1
  """aioamazondevices library."""
2
2
 
3
- __version__ = "1.2.0"
3
+ __version__ = "1.4.0"
4
4
 
5
5
 
6
6
  from .api import AmazonDevice, AmazonEchoApi
@@ -8,13 +8,16 @@ import uuid
8
8
  from dataclasses import dataclass
9
9
  from datetime import UTC, datetime, timedelta
10
10
  from http import HTTPStatus
11
+ from http.cookies import SimpleCookie
11
12
  from pathlib import Path
12
13
  from typing import Any, cast
13
14
  from urllib.parse import parse_qs, urlencode
14
15
 
15
16
  import orjson
17
+ from aiohttp import ClientResponse, ClientSession
18
+ from babel import Locale
16
19
  from bs4 import BeautifulSoup, Tag
17
- from httpx import URL, AsyncClient, Response
20
+ from yarl import URL
18
21
 
19
22
  from .const import (
20
23
  _LOGGER,
@@ -25,6 +28,8 @@ from .const import (
25
28
  AMAZON_CLIENT_OS,
26
29
  AMAZON_DEVICE_SOFTWARE_VERSION,
27
30
  AMAZON_DEVICE_TYPE,
31
+ BIN_EXTENSION,
32
+ CSRF_COOKIE,
28
33
  DEFAULT_ASSOC_HANDLE,
29
34
  DEFAULT_HEADERS,
30
35
  DOMAIN_BY_ISO3166_COUNTRY,
@@ -48,6 +53,7 @@ class AmazonDevice:
48
53
  capabilities: list[str]
49
54
  device_family: str
50
55
  device_type: str
56
+ device_owner_customer_id: str
51
57
  online: bool
52
58
  serial_number: str
53
59
  software_version: str
@@ -82,22 +88,23 @@ class AmazonEchoApi:
82
88
 
83
89
  self._login_email = login_email
84
90
  self._login_password = login_password
91
+ self._login_country_code = country_code
85
92
  self._domain = domain
86
93
  self._cookies = self._build_init_cookies()
94
+ self._csrf_cookie: str | None = None
87
95
  self._headers = DEFAULT_HEADERS
88
96
  self._save_raw_data = save_raw_data
89
97
  self._login_stored_data = login_data
90
98
  self._serial = self._serial_number()
91
- self._website_cookies: dict[str, Any] = self._load_website_cookies()
92
99
 
93
- self.session: AsyncClient
100
+ self.session: ClientSession
94
101
 
95
- def _load_website_cookies(self) -> dict[str, Any]:
102
+ def _load_website_cookies(self) -> list[SimpleCookie]:
96
103
  """Get website cookies, if avaliables."""
97
104
  if not self._login_stored_data:
98
- return {}
105
+ return []
99
106
 
100
- return cast("dict", self._login_stored_data["website_cookies"])
107
+ return cast("list", self._login_stored_data["website_cookies"])
101
108
 
102
109
  def _serial_number(self) -> str:
103
110
  """Get or calculate device serial number."""
@@ -208,13 +215,11 @@ class AmazonEchoApi:
208
215
 
209
216
  def _client_session(self) -> None:
210
217
  """Create HTTP client session."""
211
- if not hasattr(self, "session") or self.session.is_closed:
212
- _LOGGER.debug("Creating HTTP session (httpx)")
213
- self.session = AsyncClient(
214
- base_url=f"https://www.amazon.{self._domain}",
218
+ if not hasattr(self, "session") or self.session.closed:
219
+ _LOGGER.debug("Creating HTTP session (aiohttp)")
220
+ self.session = ClientSession(
215
221
  headers=DEFAULT_HEADERS,
216
222
  cookies=self._cookies,
217
- follow_redirects=True,
218
223
  )
219
224
 
220
225
  async def _session_request(
@@ -223,7 +228,7 @@ class AmazonEchoApi:
223
228
  url: str,
224
229
  input_data: dict[str, Any] | None = None,
225
230
  json_data: bool = False,
226
- ) -> tuple[BeautifulSoup, Response]:
231
+ ) -> tuple[BeautifulSoup, ClientResponse]:
227
232
  """Return request response context data."""
228
233
  _LOGGER.debug(
229
234
  "%s request: %s with payload %s [json=%s]",
@@ -232,28 +237,41 @@ class AmazonEchoApi:
232
237
  input_data,
233
238
  json_data,
234
239
  )
240
+
241
+ headers = DEFAULT_HEADERS
242
+ if self._csrf_cookie and CSRF_COOKIE not in headers:
243
+ csrf = {CSRF_COOKIE: self._csrf_cookie}
244
+ _LOGGER.debug("Adding <%s> to headers", csrf)
245
+ headers.update(csrf)
246
+
247
+ if json_data:
248
+ json_header = {"Content-Type": "application/json"}
249
+ _LOGGER.debug("Adding %s to headers", json_header)
250
+ headers.update(json_header)
251
+
235
252
  resp = await self.session.request(
236
253
  method,
237
254
  url,
238
255
  data=input_data if not json_data else orjson.dumps(input_data),
239
- cookies=self._website_cookies,
240
- headers={"Content-Type": "application/json"} if json_data else None,
256
+ cookies=self._load_website_cookies(),
257
+ headers=headers,
258
+ allow_redirects=True,
241
259
  )
242
260
  content_type: str = resp.headers.get("Content-Type", "")
243
261
  _LOGGER.debug(
244
262
  "Response %s for url %s with content type: %s",
245
- resp.status_code,
263
+ resp.status,
246
264
  url,
247
265
  content_type,
248
266
  )
249
267
 
250
268
  await self._save_to_file(
251
- resp.text,
269
+ await resp.text(),
252
270
  url,
253
271
  mimetypes.guess_extension(content_type.split(";")[0]) or ".raw",
254
272
  )
255
273
 
256
- return BeautifulSoup(resp.content, "html.parser"), resp
274
+ return BeautifulSoup(await resp.read(), "html.parser"), resp
257
275
 
258
276
  async def _save_to_file(
259
277
  self,
@@ -278,7 +296,7 @@ class AmazonEchoApi:
278
296
 
279
297
  if type(raw_data) is dict:
280
298
  data = orjson.dumps(raw_data, option=orjson.OPT_INDENT_2).decode("utf-8")
281
- elif extension == HTML_EXTENSION:
299
+ elif extension in [HTML_EXTENSION, BIN_EXTENSION]:
282
300
  data = raw_data
283
301
  else:
284
302
  data = orjson.dumps(
@@ -347,7 +365,7 @@ class AmazonEchoApi:
347
365
  )
348
366
  resp_json = resp.json()
349
367
 
350
- if resp.status_code != HTTPStatus.OK:
368
+ if resp.status != HTTPStatus.OK:
351
369
  _LOGGER.error(
352
370
  "Cannot register device for %s: %s",
353
371
  self._login_email,
@@ -456,6 +474,7 @@ class AmazonEchoApi:
456
474
  }
457
475
 
458
476
  register_device = await self._register_device(device_login_data)
477
+ self._login_stored_data = register_device
459
478
 
460
479
  _LOGGER.info("Register device: %s", register_device)
461
480
  return register_device
@@ -481,8 +500,8 @@ class AmazonEchoApi:
481
500
  async def close(self) -> None:
482
501
  """Close http client session."""
483
502
  if hasattr(self, "session"):
484
- _LOGGER.debug("Closing HTTP session (httpx)")
485
- await self.session.aclose()
503
+ _LOGGER.debug("Closing HTTP session (aiohttp)")
504
+ await self.session.close()
486
505
 
487
506
  async def get_devices_data(
488
507
  self,
@@ -495,12 +514,16 @@ class AmazonEchoApi:
495
514
  url=f"https://alexa.amazon.{self._domain}{URI_QUERIES[key]}",
496
515
  )
497
516
  _LOGGER.debug("Response URL: %s", raw_resp.url)
498
- response_code = raw_resp.status_code
499
- _LOGGER.debug("Response code: %s", response_code)
517
+ response_code = raw_resp.status
518
+ _LOGGER.debug("Response code: |%s|", response_code)
500
519
 
501
- response_data = raw_resp.text
520
+ response_data = await raw_resp.text()
502
521
  _LOGGER.debug("Response data: |%s|", response_data)
503
- json_data = {} if len(response_data) == 0 else raw_resp.json()
522
+
523
+ if not self._csrf_cookie:
524
+ self._csrf_cookie = raw_resp.cookies.get(CSRF_COOKIE).value
525
+
526
+ json_data = {} if len(response_data) == 0 else await raw_resp.json()
504
527
 
505
528
  _LOGGER.debug("JSON data: |%s|", json_data)
506
529
 
@@ -527,6 +550,7 @@ class AmazonEchoApi:
527
550
  capabilities=device[NODE_DEVICES]["capabilities"],
528
551
  device_family=device[NODE_DEVICES]["deviceFamily"],
529
552
  device_type=device[NODE_DEVICES]["deviceType"],
553
+ device_owner_customer_id=device[NODE_DEVICES]["deviceOwnerCustomerId"],
530
554
  online=device[NODE_DEVICES]["online"],
531
555
  serial_number=serial_number,
532
556
  software_version=device[NODE_DEVICES]["softwareVersion"],
@@ -543,14 +567,14 @@ class AmazonEchoApi:
543
567
  method="GET",
544
568
  url=f"https://alexa.amazon.{self._domain}/api/bootstrap?version=0",
545
569
  )
546
- if raw_resp.status_code != HTTPStatus.OK:
570
+ if raw_resp.status != HTTPStatus.OK:
547
571
  _LOGGER.debug(
548
572
  "Session not authenticated: reply error %s",
549
- raw_resp.status_code,
573
+ raw_resp.status,
550
574
  )
551
575
  return False
552
576
 
553
- resp_json = raw_resp.json()
577
+ resp_json = await raw_resp.json()
554
578
  if not (authentication := resp_json.get("authentication")):
555
579
  _LOGGER.debug('Session not authenticated: reply missing "authentication"')
556
580
  return False
@@ -558,3 +582,61 @@ class AmazonEchoApi:
558
582
  authenticated = authentication.get("authenticated")
559
583
  _LOGGER.debug("Session authenticated: %s", authenticated)
560
584
  return bool(authenticated)
585
+
586
+ async def call_alexa_speak(
587
+ self,
588
+ device: AmazonDevice,
589
+ message_body: str,
590
+ ) -> dict[str, Any]:
591
+ """Call Alexa.Speak to send a message."""
592
+ locale_data = Locale.parse(f"und_{self._login_country_code}")
593
+ locale = f"{locale_data.language}-{locale_data.language}"
594
+
595
+ if not self._login_stored_data:
596
+ _LOGGER.warning("Trying to send message before login")
597
+ return {}
598
+
599
+ sequence = {
600
+ "@type": "com.amazon.alexa.behaviors.model.Sequence",
601
+ "startNode": {
602
+ "@type": "com.amazon.alexa.behaviors.model.SerialNode",
603
+ "nodesToExecute": [
604
+ {
605
+ "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode", # noqa: E501
606
+ "type": "Alexa.Speak",
607
+ "operationPayload": {
608
+ "deviceType": device.device_type,
609
+ "deviceSerialNumber": device.serial_number,
610
+ "locale": locale,
611
+ "customerId": device.device_owner_customer_id,
612
+ "textToSpeak": message_body,
613
+ "target": {
614
+ "customerId": device.device_owner_customer_id,
615
+ "devices": [
616
+ {
617
+ "deviceSerialNumber": device.serial_number,
618
+ "deviceTypeId": device.device_type,
619
+ },
620
+ ],
621
+ },
622
+ "skillId": "amzn1.ask.1p.saysomething",
623
+ },
624
+ },
625
+ ],
626
+ },
627
+ }
628
+ node_data = {
629
+ "behaviorId": "PREVIEW",
630
+ "sequenceJson": orjson.dumps(sequence).decode("utf-8"),
631
+ "status": "ENABLED",
632
+ }
633
+
634
+ _LOGGER.debug("Preview data payload: %s", node_data)
635
+ await self._session_request(
636
+ method="POST",
637
+ url=f"https://alexa.amazon.{self._domain}/api/behaviors/preview",
638
+ input_data=node_data,
639
+ json_data=True,
640
+ )
641
+
642
+ return node_data
@@ -43,6 +43,7 @@ DEFAULT_HEADERS = {
43
43
  "Accept-Language": "en-US",
44
44
  "Accept-Encoding": "gzip",
45
45
  }
46
+ CSRF_COOKIE = "csrf"
46
47
 
47
48
  NODE_DEVICES = "devices"
48
49
  NODE_DO_NOT_DISTURB = "doNotDisturbDeviceStatusList"
@@ -60,6 +61,7 @@ URI_QUERIES = {
60
61
  SAVE_PATH = "out"
61
62
  HTML_EXTENSION = ".html"
62
63
  JSON_EXTENSION = ".json"
64
+ BIN_EXTENSION = ".bin"
63
65
 
64
66
  DEVICE_TYPE_TO_MODEL = {
65
67
  "A1RABVCI4QCIKC": "Echo Dot (Gen3)",