aioamazondevices 0.0.0__py3-none-any.whl → 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.
@@ -1,3 +1,17 @@
1
1
  """aioamazondevices library."""
2
2
 
3
- __version__ = "0.0.0"
3
+ __version__ = "0.1.0"
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,262 @@
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
+ locale = DOMAIN_BY_COUNTRY.get(login_country_code)
74
+ domain = locale["domain"] if locale else login_country_code
75
+
76
+ assoc_handle = "amzn_dp_project_dee_ios"
77
+ if not locale:
78
+ assoc_handle += f"_{login_country_code}"
79
+ self._assoc_handle = assoc_handle
80
+
81
+ self._login_email = login_email
82
+ self._login_password = login_password
83
+ self._domain = domain
84
+ self._url = f"https://www.amazon.{domain}"
85
+ self._cookies = _build_init_cookies()
86
+ self._headers = DEFAULT_HEADERS
87
+
88
+ self.session: AsyncClient
89
+
90
+ def _create_code_verifier(self, length: int = 32) -> bytes:
91
+ """Create code verifier."""
92
+ verifier = secrets.token_bytes(length)
93
+ return base64.urlsafe_b64encode(verifier).rstrip(b"=")
94
+
95
+ def _create_s256_code_challenge(self, verifier: bytes) -> bytes:
96
+ """Create S256 code challenge."""
97
+ m = hashlib.sha256(verifier)
98
+ return base64.urlsafe_b64encode(m.digest()).rstrip(b"=")
99
+
100
+ def _build_client_id(self) -> str:
101
+ """Build client ID."""
102
+ serial = uuid.uuid4().hex.upper()
103
+ client_id = serial.encode() + AMAZON_SERIAL_NUMBER
104
+ return client_id.hex()
105
+
106
+ def _build_oauth_url(
107
+ self,
108
+ ) -> str:
109
+ """Build the url to login to Amazon as a Mobile device."""
110
+ client_id = self._build_client_id()
111
+ code_challenge = self._create_s256_code_challenge(self._create_code_verifier())
112
+
113
+ oauth_params = {
114
+ "openid.oa2.response_type": "code",
115
+ "openid.oa2.code_challenge_method": "S256",
116
+ "openid.oa2.code_challenge": code_challenge,
117
+ "openid.return_to": f"https://www.amazon.{self._domain}/ap/maplanding",
118
+ "openid.assoc_handle": self._assoc_handle,
119
+ "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
120
+ "accountStatusPolicy": "P1",
121
+ "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
122
+ "openid.mode": "checkid_setup",
123
+ "openid.ns.oa2": "http://www.amazon.com/ap/ext/oauth/2",
124
+ "openid.oa2.client_id": f"device:{client_id}",
125
+ "openid.ns.pape": "http://specs.openid.net/extensions/pape/1.0",
126
+ "openid.oa2.scope": "device_auth_access",
127
+ "forceMobileLayout": "true",
128
+ "openid.ns": "http://specs.openid.net/auth/2.0",
129
+ "openid.pape.max_auth_age": "0",
130
+ }
131
+
132
+ return f"https://www.amazon.{self._domain}/ap/signin?{urlencode(oauth_params)}"
133
+
134
+ def _get_inputs_from_soup(self, soup: BeautifulSoup) -> dict[str, str]:
135
+ """Extract hidden form input fields from a Amazon login page."""
136
+ form = soup.find("form", {"name": "signIn"}) or soup.find("form")
137
+
138
+ if not isinstance(form, Tag):
139
+ raise TypeError("No form found in page or something other is going wrong.")
140
+
141
+ inputs = {}
142
+ for field in form.find_all("input"):
143
+ if field.get("type") and field["type"] == "hidden":
144
+ inputs[field["name"]] = field.get("value", "")
145
+
146
+ return inputs
147
+
148
+ def _get_request_from_soup(self, soup: BeautifulSoup) -> tuple[str, str]:
149
+ """Extract URL and method for the next request."""
150
+ _LOGGER.debug("Get request data from HTML source")
151
+ form = soup.find("form")
152
+ if isinstance(form, Tag):
153
+ method = form["method"]
154
+ url = form["action"]
155
+ if isinstance(method, str) and isinstance(url, str):
156
+ return method, url
157
+ raise TypeError("Unable to extract form data from response.")
158
+
159
+ def _client_session(self) -> None:
160
+ """Create httpx ClientSession."""
161
+ if not hasattr(self, "session") or self.session.is_closed:
162
+ _LOGGER.debug("Creating HTTP ClientSession")
163
+ self.session = AsyncClient(
164
+ base_url=f"https://www.amazon.{self._domain}",
165
+ headers=DEFAULT_HEADERS,
166
+ cookies=self._cookies,
167
+ follow_redirects=True,
168
+ )
169
+
170
+ async def _session_request(
171
+ self,
172
+ method: str,
173
+ url: str,
174
+ input_data: dict[str, Any] | None = None,
175
+ ) -> tuple[BeautifulSoup, Response]:
176
+ """Return request response context data."""
177
+ _LOGGER.debug("%s request: %s with payload %s", method, url, input_data)
178
+ resp = await self.session.request(
179
+ method,
180
+ url,
181
+ data=input_data,
182
+ )
183
+ return BeautifulSoup(resp.content, "html.parser"), resp
184
+
185
+ async def login(self, otp_code: str) -> bool:
186
+ """Login to Amazon."""
187
+ _LOGGER.debug("Logging-in for %s [otp code %s]", self._login_email, otp_code)
188
+ self._client_session()
189
+
190
+ _LOGGER.debug("Build oauth URL")
191
+ login_url = self._build_oauth_url()
192
+
193
+ login_soup, _ = await self._session_request("GET", login_url)
194
+ login_method, login_url = self._get_request_from_soup(login_soup)
195
+ login_inputs = self._get_inputs_from_soup(login_soup)
196
+ login_inputs["email"] = self._login_email
197
+ login_inputs["password"] = self._login_password
198
+
199
+ login_soup, _ = await self._session_request(
200
+ login_method,
201
+ login_url,
202
+ login_inputs,
203
+ )
204
+
205
+ if not login_soup.find("input", id="auth-mfa-otpcode"):
206
+ _LOGGER.debug('Cannot find "auth-mfa-otpcode" in html source')
207
+ raise CannotAuthenticate
208
+
209
+ login_method, login_url = self._get_request_from_soup(login_soup)
210
+
211
+ login_inputs = self._get_inputs_from_soup(login_soup)
212
+ login_inputs["otpCode"] = otp_code
213
+ login_inputs["mfaSubmit"] = "Submit"
214
+ login_inputs["rememberDevice"] = "false"
215
+
216
+ login_soup, login_resp = await self._session_request(
217
+ login_method,
218
+ login_url,
219
+ login_inputs,
220
+ )
221
+
222
+ authcode_url = None
223
+ _LOGGER.debug("Login query: %s", login_resp.url.query)
224
+ if b"openid.oa2.authorization_code" in login_resp.url.query:
225
+ authcode_url = login_resp.url
226
+ elif len(login_resp.history) > 0:
227
+ for history in login_resp.history:
228
+ if b"openid.oa2.authorization_code" in history.url.query:
229
+ authcode_url = history.url
230
+ break
231
+
232
+ if authcode_url is None:
233
+ raise CannotAuthenticate
234
+
235
+ return True
236
+
237
+ async def close(self) -> None:
238
+ """Close httpx session."""
239
+ if hasattr(self, "session"):
240
+ _LOGGER.debug("Closing httpx session")
241
+ await self.session.aclose()
242
+
243
+ async def get_devices_data(
244
+ self,
245
+ ) -> dict[str, Any]:
246
+ """Get Amazon devices data."""
247
+ devices = {}
248
+ for key in URI_QUERIES:
249
+ _, raw_resp = await self._session_request(
250
+ "GET",
251
+ f"https://alexa.amazon.{self._domain}{URI_QUERIES[key]}",
252
+ )
253
+
254
+ devices.update(
255
+ {
256
+ key: orjson.loads(
257
+ raw_resp.text,
258
+ ),
259
+ },
260
+ )
261
+
262
+ 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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: aioamazondevices
3
- Version: 0.0.0
3
+ Version: 0.1.0
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
@@ -0,0 +1,9 @@
1
+ aioamazondevices/__init__.py,sha256=U15rPzIFIx526iBdCgherokeEZMIr1qB8g-kFW28oIM,276
2
+ aioamazondevices/api.py,sha256=r8WeERP8FBwzj4gJzwTwem69102Hg6dmz-Q5tJrPJV8,8856
3
+ aioamazondevices/const.py,sha256=bZaeO8AeJbDc5hdlbJ3cMwM9teTgYhExSR1oEpRFMLk,1089
4
+ aioamazondevices/exceptions.py,sha256=tERMur_gry9TmU3UyzndJO_CLViISn4b8ClrRbryFy8,444
5
+ aioamazondevices/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ aioamazondevices-0.1.0.dist-info/LICENSE,sha256=sS48k5sp9bFV-NSHDfAJuTZZ_-AP9ZDqUzQ9sffGlsg,11346
7
+ aioamazondevices-0.1.0.dist-info/METADATA,sha256=wfe_Z0hPTGrccs3lR6eliUWVRzh8M_lMuSL3vIqPHh0,4680
8
+ aioamazondevices-0.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
9
+ aioamazondevices-0.1.0.dist-info/RECORD,,
aioamazondevices/main.py DELETED
@@ -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
@@ -1,7 +0,0 @@
1
- aioamazondevices/__init__.py,sha256=YAxL3dFa4QRgbbHi-ceTLo8z-NSU3bMm8soshwv82K8,55
2
- aioamazondevices/main.py,sha256=cAKn2_mYgu71JaXudaa1lB1lG2-Qc1_VVS7Ks3OiPCU,118
3
- aioamazondevices/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- aioamazondevices-0.0.0.dist-info/LICENSE,sha256=sS48k5sp9bFV-NSHDfAJuTZZ_-AP9ZDqUzQ9sffGlsg,11346
5
- aioamazondevices-0.0.0.dist-info/METADATA,sha256=CSCbiW7hHVUmUY6Yu4W1EYQqnxdDG4iT4fbPqYCBxLo,4630
6
- aioamazondevices-0.0.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
7
- aioamazondevices-0.0.0.dist-info/RECORD,,