aioamazondevices 0.0.0__tar.gz → 0.1.1__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.1
2
2
  Name: aioamazondevices
3
- Version: 0.0.0
3
+ Version: 0.1.1
4
4
  Summary: Python library to control Amazon devices
5
5
  Home-page: https://github.com/chemelli74/aioamazondevices
6
6
  License: Apache Software License 2.0
@@ -16,7 +16,9 @@ Classifier: Programming Language :: Python :: 3
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Topic :: Software Development :: Libraries
19
- Requires-Dist: aiohttp
19
+ Requires-Dist: beautifulsoup4
20
+ Requires-Dist: httpx
21
+ Requires-Dist: orjson
20
22
  Project-URL: Bug Tracker, https://github.com/chemelli74/aioamazondevices/issues
21
23
  Project-URL: Changelog, https://github.com/chemelli74/aioamazondevices/blob/main/CHANGELOG.md
22
24
  Project-URL: Repository, https://github.com/chemelli74/aioamazondevices
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "aioamazondevices"
3
- version = "0.0.0"
3
+ version = "0.1.1"
4
4
  description = "Python library to control Amazon devices"
5
5
  authors = ["Simone Chemelli <simone.chemelli@gmail.com>"]
6
6
  license = "Apache Software License 2.0"
@@ -23,7 +23,9 @@ packages = [
23
23
 
24
24
  [tool.poetry.dependencies]
25
25
  python = "^3.11"
26
- aiohttp = "*"
26
+ beautifulsoup4 = "*"
27
+ httpx = "*"
28
+ orjson = "*"
27
29
 
28
30
  [tool.poetry.group.dev.dependencies]
29
31
  pytest = "^8.1"
@@ -0,0 +1,17 @@
1
+ """aioamazondevices library."""
2
+
3
+ __version__ = "0.1.1"
4
+
5
+
6
+ from .api import AmazonDevice, AmazonEchoApi
7
+ from .exceptions import (
8
+ CannotAuthenticate,
9
+ CannotConnect,
10
+ )
11
+
12
+ __all__ = [
13
+ "AmazonDevice",
14
+ "AmazonEchoApi",
15
+ "CannotConnect",
16
+ "CannotAuthenticate",
17
+ ]
@@ -0,0 +1,265 @@
1
+ """Support for Amazon devices."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import secrets
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+ from urllib.parse import urlencode
10
+
11
+ import orjson
12
+ from bs4 import BeautifulSoup, Tag
13
+ from httpx import AsyncClient, Response
14
+
15
+ from .const import (
16
+ _LOGGER,
17
+ AMAZON_APP_BUNDLE_ID,
18
+ AMAZON_APP_ID,
19
+ AMAZON_APP_VERSION,
20
+ AMAZON_SERIAL_NUMBER,
21
+ AMAZON_SOFTWARE_VERSION,
22
+ DEFAULT_HEADERS,
23
+ DOMAIN_BY_COUNTRY,
24
+ URI_QUERIES,
25
+ )
26
+ from .exceptions import CannotAuthenticate
27
+
28
+
29
+ @dataclass
30
+ class AmazonDevice:
31
+ """Amazon device class."""
32
+
33
+ connected: bool
34
+ connection_type: str
35
+ ip_address: str
36
+ name: str
37
+ mac: str
38
+ type: str
39
+ wifi: str
40
+
41
+
42
+ def _build_init_cookies() -> dict[str, str]:
43
+ """Build initial cookies to prevent captcha in most cases."""
44
+ token_bytes = secrets.token_bytes(313)
45
+ frc = base64.b64encode(token_bytes).decode("ascii").rstrip("=")
46
+
47
+ map_md_dict = {
48
+ "device_user_dictionary": [],
49
+ "device_registration_data": {
50
+ "software_version": AMAZON_SOFTWARE_VERSION,
51
+ },
52
+ "app_identifier": {
53
+ "app_version": AMAZON_APP_VERSION,
54
+ "bundle_id": AMAZON_APP_BUNDLE_ID,
55
+ },
56
+ }
57
+ map_md_str = orjson.dumps(map_md_dict).decode("utf-8")
58
+ map_md = base64.b64encode(map_md_str.encode()).decode().rstrip("=")
59
+
60
+ return {"frc": frc, "map-md": map_md, "amzn-app-id": AMAZON_APP_ID}
61
+
62
+
63
+ class AmazonEchoApi:
64
+ """Queries Amazon for Echo devices."""
65
+
66
+ def __init__(
67
+ self,
68
+ login_country_code: str,
69
+ login_email: str,
70
+ login_password: str,
71
+ ) -> None:
72
+ """Initialize the scanner."""
73
+ # Force country digits as lower case
74
+ country_code = login_country_code.lower()
75
+
76
+ locale = DOMAIN_BY_COUNTRY.get(country_code)
77
+ domain = locale["domain"] if locale else country_code
78
+
79
+ assoc_handle = "amzn_dp_project_dee_ios"
80
+ if not locale:
81
+ assoc_handle += f"_{country_code}"
82
+ self._assoc_handle = assoc_handle
83
+
84
+ self._login_email = login_email
85
+ self._login_password = login_password
86
+ self._domain = domain
87
+ self._url = f"https://www.amazon.{domain}"
88
+ self._cookies = _build_init_cookies()
89
+ self._headers = DEFAULT_HEADERS
90
+
91
+ self.session: AsyncClient
92
+
93
+ def _create_code_verifier(self, length: int = 32) -> bytes:
94
+ """Create code verifier."""
95
+ verifier = secrets.token_bytes(length)
96
+ return base64.urlsafe_b64encode(verifier).rstrip(b"=")
97
+
98
+ def _create_s256_code_challenge(self, verifier: bytes) -> bytes:
99
+ """Create S256 code challenge."""
100
+ m = hashlib.sha256(verifier)
101
+ return base64.urlsafe_b64encode(m.digest()).rstrip(b"=")
102
+
103
+ def _build_client_id(self) -> str:
104
+ """Build client ID."""
105
+ serial = uuid.uuid4().hex.upper()
106
+ client_id = serial.encode() + AMAZON_SERIAL_NUMBER
107
+ return client_id.hex()
108
+
109
+ def _build_oauth_url(
110
+ self,
111
+ ) -> str:
112
+ """Build the url to login to Amazon as a Mobile device."""
113
+ client_id = self._build_client_id()
114
+ code_challenge = self._create_s256_code_challenge(self._create_code_verifier())
115
+
116
+ oauth_params = {
117
+ "openid.oa2.response_type": "code",
118
+ "openid.oa2.code_challenge_method": "S256",
119
+ "openid.oa2.code_challenge": code_challenge,
120
+ "openid.return_to": f"https://www.amazon.{self._domain}/ap/maplanding",
121
+ "openid.assoc_handle": self._assoc_handle,
122
+ "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
123
+ "accountStatusPolicy": "P1",
124
+ "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
125
+ "openid.mode": "checkid_setup",
126
+ "openid.ns.oa2": "http://www.amazon.com/ap/ext/oauth/2",
127
+ "openid.oa2.client_id": f"device:{client_id}",
128
+ "openid.ns.pape": "http://specs.openid.net/extensions/pape/1.0",
129
+ "openid.oa2.scope": "device_auth_access",
130
+ "forceMobileLayout": "true",
131
+ "openid.ns": "http://specs.openid.net/auth/2.0",
132
+ "openid.pape.max_auth_age": "0",
133
+ }
134
+
135
+ return f"https://www.amazon.{self._domain}/ap/signin?{urlencode(oauth_params)}"
136
+
137
+ def _get_inputs_from_soup(self, soup: BeautifulSoup) -> dict[str, str]:
138
+ """Extract hidden form input fields from a Amazon login page."""
139
+ form = soup.find("form", {"name": "signIn"}) or soup.find("form")
140
+
141
+ if not isinstance(form, Tag):
142
+ raise TypeError("No form found in page or something other is going wrong.")
143
+
144
+ inputs = {}
145
+ for field in form.find_all("input"):
146
+ if field.get("type") and field["type"] == "hidden":
147
+ inputs[field["name"]] = field.get("value", "")
148
+
149
+ return inputs
150
+
151
+ def _get_request_from_soup(self, soup: BeautifulSoup) -> tuple[str, str]:
152
+ """Extract URL and method for the next request."""
153
+ _LOGGER.debug("Get request data from HTML source")
154
+ form = soup.find("form")
155
+ if isinstance(form, Tag):
156
+ method = form["method"]
157
+ url = form["action"]
158
+ if isinstance(method, str) and isinstance(url, str):
159
+ return method, url
160
+ raise TypeError("Unable to extract form data from response.")
161
+
162
+ def _client_session(self) -> None:
163
+ """Create httpx ClientSession."""
164
+ if not hasattr(self, "session") or self.session.is_closed:
165
+ _LOGGER.debug("Creating HTTP ClientSession")
166
+ self.session = AsyncClient(
167
+ base_url=f"https://www.amazon.{self._domain}",
168
+ headers=DEFAULT_HEADERS,
169
+ cookies=self._cookies,
170
+ follow_redirects=True,
171
+ )
172
+
173
+ async def _session_request(
174
+ self,
175
+ method: str,
176
+ url: str,
177
+ input_data: dict[str, Any] | None = None,
178
+ ) -> tuple[BeautifulSoup, Response]:
179
+ """Return request response context data."""
180
+ _LOGGER.debug("%s request: %s with payload %s", method, url, input_data)
181
+ resp = await self.session.request(
182
+ method,
183
+ url,
184
+ data=input_data,
185
+ )
186
+ return BeautifulSoup(resp.content, "html.parser"), resp
187
+
188
+ async def login(self, otp_code: str) -> bool:
189
+ """Login to Amazon."""
190
+ _LOGGER.debug("Logging-in for %s [otp code %s]", self._login_email, otp_code)
191
+ self._client_session()
192
+
193
+ _LOGGER.debug("Build oauth URL")
194
+ login_url = self._build_oauth_url()
195
+
196
+ login_soup, _ = await self._session_request("GET", login_url)
197
+ login_method, login_url = self._get_request_from_soup(login_soup)
198
+ login_inputs = self._get_inputs_from_soup(login_soup)
199
+ login_inputs["email"] = self._login_email
200
+ login_inputs["password"] = self._login_password
201
+
202
+ login_soup, _ = await self._session_request(
203
+ login_method,
204
+ login_url,
205
+ login_inputs,
206
+ )
207
+
208
+ if not login_soup.find("input", id="auth-mfa-otpcode"):
209
+ _LOGGER.debug('Cannot find "auth-mfa-otpcode" in html source')
210
+ raise CannotAuthenticate
211
+
212
+ login_method, login_url = self._get_request_from_soup(login_soup)
213
+
214
+ login_inputs = self._get_inputs_from_soup(login_soup)
215
+ login_inputs["otpCode"] = otp_code
216
+ login_inputs["mfaSubmit"] = "Submit"
217
+ login_inputs["rememberDevice"] = "false"
218
+
219
+ login_soup, login_resp = await self._session_request(
220
+ login_method,
221
+ login_url,
222
+ login_inputs,
223
+ )
224
+
225
+ authcode_url = None
226
+ _LOGGER.debug("Login query: %s", login_resp.url.query)
227
+ if b"openid.oa2.authorization_code" in login_resp.url.query:
228
+ authcode_url = login_resp.url
229
+ elif len(login_resp.history) > 0:
230
+ for history in login_resp.history:
231
+ if b"openid.oa2.authorization_code" in history.url.query:
232
+ authcode_url = history.url
233
+ break
234
+
235
+ if authcode_url is None:
236
+ raise CannotAuthenticate
237
+
238
+ return True
239
+
240
+ async def close(self) -> None:
241
+ """Close httpx session."""
242
+ if hasattr(self, "session"):
243
+ _LOGGER.debug("Closing httpx session")
244
+ await self.session.aclose()
245
+
246
+ async def get_devices_data(
247
+ self,
248
+ ) -> dict[str, Any]:
249
+ """Get Amazon devices data."""
250
+ devices = {}
251
+ for key in URI_QUERIES:
252
+ _, raw_resp = await self._session_request(
253
+ "GET",
254
+ f"https://alexa.amazon.{self._domain}{URI_QUERIES[key]}",
255
+ )
256
+
257
+ devices.update(
258
+ {
259
+ key: orjson.loads(
260
+ raw_resp.text,
261
+ ),
262
+ },
263
+ )
264
+
265
+ return devices
@@ -0,0 +1,47 @@
1
+ """Constants for Amazon devices."""
2
+
3
+ import logging
4
+
5
+ _LOGGER = logging.getLogger(__package__)
6
+
7
+ DOMAIN_BY_COUNTRY = {
8
+ "us": {
9
+ "domain": "com",
10
+ "openid.assoc_handle": "amzn_dp_project_dee_ios",
11
+ },
12
+ "uk": {
13
+ "domain": "co.uk",
14
+ },
15
+ "au": {
16
+ "domain": "com.au",
17
+ },
18
+ "jp": {
19
+ "domain": "co.jp",
20
+ },
21
+ "br": {
22
+ "domain": "com.br",
23
+ },
24
+ }
25
+
26
+ DEFAULT_HEADERS = {
27
+ "User-Agent": (
28
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) "
29
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
30
+ ),
31
+ "Accept-Language": "en-US",
32
+ "Accept-Encoding": "gzip",
33
+ }
34
+
35
+ URI_QUERIES = {
36
+ "base": "/api/devices-v2/device",
37
+ "status": "/api/dnd/device-status-list",
38
+ "preferences": "/api/device-preferences",
39
+ "automations": "/api/behaviors/v2/automations",
40
+ "bluetooth": "/api/bluetooth",
41
+ }
42
+
43
+ AMAZON_APP_BUNDLE_ID = "com.audible.iphone"
44
+ AMAZON_APP_ID = "MAPiOSLib/6.0/ToHideRetailLink"
45
+ AMAZON_APP_VERSION = "3.56.2"
46
+ AMAZON_SOFTWARE_VERSION = "35602678"
47
+ AMAZON_SERIAL_NUMBER = b"#A2CZJZGLK2JJVM"
@@ -0,0 +1,19 @@
1
+ """Comelit SimpleHome library exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AmazonError(Exception):
7
+ """Base class for aioamazondevices errors."""
8
+
9
+
10
+ class CannotConnect(AmazonError):
11
+ """Exception raised when connection fails."""
12
+
13
+
14
+ class CannotAuthenticate(AmazonError):
15
+ """Exception raised when credentials are incorrect."""
16
+
17
+
18
+ class CannotRetrieveData(AmazonError):
19
+ """Exception raised when data retrieval fails."""
@@ -1,3 +0,0 @@
1
- """aioamazondevices library."""
2
-
3
- __version__ = "0.0.0"
@@ -1,6 +0,0 @@
1
- """Support for Amazon devices."""
2
-
3
-
4
- def add(n1: int, n2: int) -> int:
5
- """Add the arguments."""
6
- return n1 + n2