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.
- {aioamazondevices-0.0.0 → aioamazondevices-0.1.1}/PKG-INFO +4 -2
- {aioamazondevices-0.0.0 → aioamazondevices-0.1.1}/pyproject.toml +4 -2
- aioamazondevices-0.1.1/src/aioamazondevices/__init__.py +17 -0
- aioamazondevices-0.1.1/src/aioamazondevices/api.py +265 -0
- aioamazondevices-0.1.1/src/aioamazondevices/const.py +47 -0
- aioamazondevices-0.1.1/src/aioamazondevices/exceptions.py +19 -0
- aioamazondevices-0.0.0/src/aioamazondevices/__init__.py +0 -3
- aioamazondevices-0.0.0/src/aioamazondevices/main.py +0 -6
- {aioamazondevices-0.0.0 → aioamazondevices-0.1.1}/LICENSE +0 -0
- {aioamazondevices-0.0.0 → aioamazondevices-0.1.1}/README.md +0 -0
- {aioamazondevices-0.0.0 → aioamazondevices-0.1.1}/src/aioamazondevices/py.typed +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: aioamazondevices
|
3
|
-
Version: 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:
|
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.
|
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
|
-
|
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."""
|
File without changes
|
File without changes
|
File without changes
|