aioamazondevices 0.8.0__tar.gz → 0.10.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.
- {aioamazondevices-0.8.0 → aioamazondevices-0.10.0}/PKG-INFO +3 -1
- {aioamazondevices-0.8.0 → aioamazondevices-0.10.0}/pyproject.toml +4 -2
- {aioamazondevices-0.8.0 → aioamazondevices-0.10.0}/src/aioamazondevices/__init__.py +1 -1
- {aioamazondevices-0.8.0 → aioamazondevices-0.10.0}/src/aioamazondevices/api.py +80 -22
- aioamazondevices-0.10.0/src/aioamazondevices/auth.py +315 -0
- {aioamazondevices-0.8.0 → aioamazondevices-0.10.0}/src/aioamazondevices/const.py +15 -15
- aioamazondevices-0.10.0/src/aioamazondevices/exceptions.py +51 -0
- aioamazondevices-0.8.0/src/aioamazondevices/exceptions.py +0 -23
- {aioamazondevices-0.8.0 → aioamazondevices-0.10.0}/LICENSE +0 -0
- {aioamazondevices-0.8.0 → aioamazondevices-0.10.0}/README.md +0 -0
- {aioamazondevices-0.8.0 → aioamazondevices-0.10.0}/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.10.0
|
4
4
|
Summary: Python library to control Amazon devices
|
5
5
|
Home-page: https://github.com/chemelli74/aioamazondevices
|
6
6
|
License: Apache-2.0
|
@@ -20,6 +20,8 @@ Requires-Dist: beautifulsoup4
|
|
20
20
|
Requires-Dist: colorlog
|
21
21
|
Requires-Dist: httpx
|
22
22
|
Requires-Dist: orjson
|
23
|
+
Requires-Dist: pyasn1
|
24
|
+
Requires-Dist: rsa
|
23
25
|
Project-URL: Bug Tracker, https://github.com/chemelli74/aioamazondevices/issues
|
24
26
|
Project-URL: Changelog, https://github.com/chemelli74/aioamazondevices/blob/main/CHANGELOG.md
|
25
27
|
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.10.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"
|
@@ -27,10 +27,12 @@ beautifulsoup4 = "*"
|
|
27
27
|
colorlog = "*"
|
28
28
|
httpx = "*"
|
29
29
|
orjson = "*"
|
30
|
+
pyasn1 = "*"
|
31
|
+
rsa = "*"
|
30
32
|
|
31
33
|
[tool.poetry.group.dev.dependencies]
|
32
34
|
pytest = "^8.1"
|
33
|
-
pytest-cov = "
|
35
|
+
pytest-cov = ">=5,<7"
|
34
36
|
|
35
37
|
[tool.semantic_release]
|
36
38
|
version_toml = ["pyproject.toml:tool.poetry.version"]
|
@@ -15,8 +15,9 @@ from urllib.parse import parse_qs, urlencode
|
|
15
15
|
|
16
16
|
import orjson
|
17
17
|
from bs4 import BeautifulSoup, Tag
|
18
|
-
from httpx import URL, AsyncClient, Response
|
18
|
+
from httpx import URL, AsyncClient, Auth, Response
|
19
19
|
|
20
|
+
from .auth import Authenticator
|
20
21
|
from .const import (
|
21
22
|
_LOGGER,
|
22
23
|
AMAZON_APP_BUNDLE_ID,
|
@@ -28,13 +29,14 @@ from .const import (
|
|
28
29
|
AMAZON_DEVICE_TYPE,
|
29
30
|
DEFAULT_ASSOC_HANDLE,
|
30
31
|
DEFAULT_HEADERS,
|
32
|
+
DEVICES,
|
31
33
|
DOMAIN_BY_COUNTRY,
|
32
34
|
HTML_EXTENSION,
|
33
35
|
JSON_EXTENSION,
|
34
36
|
SAVE_PATH,
|
35
37
|
URI_QUERIES,
|
36
38
|
)
|
37
|
-
from .exceptions import CannotAuthenticate, CannotRegisterDevice
|
39
|
+
from .exceptions import CannotAuthenticate, CannotRegisterDevice, WrongMethod
|
38
40
|
|
39
41
|
|
40
42
|
@dataclass
|
@@ -58,7 +60,8 @@ class AmazonEchoApi:
|
|
58
60
|
login_country_code: str,
|
59
61
|
login_email: str,
|
60
62
|
login_password: str,
|
61
|
-
|
63
|
+
login_data_file: str | None,
|
64
|
+
save_raw_data: bool = False,
|
62
65
|
) -> None:
|
63
66
|
"""Initialize the scanner."""
|
64
67
|
# Force country digits as lower case
|
@@ -79,24 +82,44 @@ class AmazonEchoApi:
|
|
79
82
|
self._url = f"https://www.amazon.{domain}"
|
80
83
|
self._cookies = self._build_init_cookies()
|
81
84
|
self._headers = DEFAULT_HEADERS
|
82
|
-
self.
|
85
|
+
self._save_raw_data = save_raw_data
|
86
|
+
self._login_stored_data: dict[str, Any] = self._load_data_file(login_data_file)
|
83
87
|
self._serial = self._serial_number()
|
88
|
+
self._website_cookies: dict[str, Any] = self._load_website_cookies()
|
84
89
|
|
85
90
|
self.session: AsyncClient
|
86
91
|
|
92
|
+
def _load_data_file(self, data_file: str | None) -> dict[str, Any]:
|
93
|
+
"""Load stored login data from file."""
|
94
|
+
if not data_file or not (file := Path(data_file)).exists():
|
95
|
+
_LOGGER.debug(
|
96
|
+
"Cannot find previous login data file <%s>",
|
97
|
+
data_file,
|
98
|
+
)
|
99
|
+
return {}
|
100
|
+
|
101
|
+
with Path.open(file, "rb") as f:
|
102
|
+
return cast(dict[str, Any], json.load(f))
|
103
|
+
|
104
|
+
def _load_website_cookies(self) -> dict[str, Any]:
|
105
|
+
"""Get website cookies, if avaliables."""
|
106
|
+
if not self._login_stored_data:
|
107
|
+
return {}
|
108
|
+
|
109
|
+
return cast(dict, self._login_stored_data["website_cookies"])
|
110
|
+
|
87
111
|
def _serial_number(self) -> str:
|
88
112
|
"""Get or calculate device serial number."""
|
89
|
-
|
90
|
-
if not fullpath.exists():
|
113
|
+
if not self._login_stored_data:
|
91
114
|
# Create a new serial number
|
92
115
|
_LOGGER.debug("Cannot find previous login data, creating new serial number")
|
93
116
|
return uuid.uuid4().hex.upper()
|
94
117
|
|
95
|
-
with Path.open(fullpath, "rb") as file:
|
96
|
-
data = json.load(file)
|
97
|
-
|
98
118
|
_LOGGER.debug("Found previous login data, loading serial number")
|
99
|
-
return cast(
|
119
|
+
return cast(
|
120
|
+
str,
|
121
|
+
self._login_stored_data["device_info"]["device_serial_number"],
|
122
|
+
)
|
100
123
|
|
101
124
|
def _build_init_cookies(self) -> dict[str, str]:
|
102
125
|
"""Build initial cookies to prevent captcha in most cases."""
|
@@ -192,7 +215,7 @@ class AmazonEchoApi:
|
|
192
215
|
parsed_url = parse_qs(url.query.decode())
|
193
216
|
return parsed_url["openid.oa2.authorization_code"][0]
|
194
217
|
|
195
|
-
def _client_session(self) -> None:
|
218
|
+
def _client_session(self, auth: Auth | None = None) -> None:
|
196
219
|
"""Create httpx ClientSession."""
|
197
220
|
if not hasattr(self, "session") or self.session.is_closed:
|
198
221
|
_LOGGER.debug("Creating HTTP ClientSession")
|
@@ -201,6 +224,7 @@ class AmazonEchoApi:
|
|
201
224
|
headers=DEFAULT_HEADERS,
|
202
225
|
cookies=self._cookies,
|
203
226
|
follow_redirects=True,
|
227
|
+
auth=auth,
|
204
228
|
)
|
205
229
|
|
206
230
|
async def _session_request(
|
@@ -215,6 +239,7 @@ class AmazonEchoApi:
|
|
215
239
|
method,
|
216
240
|
url,
|
217
241
|
data=input_data,
|
242
|
+
cookies=self._website_cookies,
|
218
243
|
)
|
219
244
|
content_type: str = resp.headers.get("Content-Type", "")
|
220
245
|
_LOGGER.debug("Response content type: %s", content_type)
|
@@ -235,7 +260,7 @@ class AmazonEchoApi:
|
|
235
260
|
output_path: str = SAVE_PATH,
|
236
261
|
) -> None:
|
237
262
|
"""Save response data to disk."""
|
238
|
-
if not self.
|
263
|
+
if not self._save_raw_data:
|
239
264
|
return
|
240
265
|
|
241
266
|
output_dir = Path(output_path)
|
@@ -366,8 +391,8 @@ class AmazonEchoApi:
|
|
366
391
|
await self._save_to_file(login_data, "login_data", JSON_EXTENSION)
|
367
392
|
return login_data
|
368
393
|
|
369
|
-
async def
|
370
|
-
"""Login to Amazon."""
|
394
|
+
async def login_mode_interactive(self, otp_code: str) -> dict[str, Any]:
|
395
|
+
"""Login to Amazon interactively via OTP."""
|
371
396
|
_LOGGER.debug("Logging-in for %s [otp code %s]", self._login_email, otp_code)
|
372
397
|
self._client_session()
|
373
398
|
|
@@ -431,6 +456,28 @@ class AmazonEchoApi:
|
|
431
456
|
_LOGGER.info("Register device: %s", register_device)
|
432
457
|
return register_device
|
433
458
|
|
459
|
+
async def login_mode_stored_data(self) -> dict[str, Any]:
|
460
|
+
"""Login to Amazon using previously stored data."""
|
461
|
+
if not self._login_stored_data:
|
462
|
+
_LOGGER.debug(
|
463
|
+
"Cannot find previous login data,\
|
464
|
+
use login_interactive() method instead",
|
465
|
+
)
|
466
|
+
raise WrongMethod
|
467
|
+
|
468
|
+
_LOGGER.debug(
|
469
|
+
"Logging-in for %s with stored data",
|
470
|
+
self._login_email,
|
471
|
+
)
|
472
|
+
|
473
|
+
auth = Authenticator.from_dict(
|
474
|
+
self._login_stored_data,
|
475
|
+
self._domain,
|
476
|
+
)
|
477
|
+
self._client_session(auth)
|
478
|
+
|
479
|
+
return self._login_stored_data
|
480
|
+
|
434
481
|
async def close(self) -> None:
|
435
482
|
"""Close httpx session."""
|
436
483
|
if hasattr(self, "session"):
|
@@ -441,7 +488,7 @@ class AmazonEchoApi:
|
|
441
488
|
self,
|
442
489
|
) -> dict[str, Any]:
|
443
490
|
"""Get Amazon devices data."""
|
444
|
-
devices = {}
|
491
|
+
devices: dict[str, Any] = {}
|
445
492
|
for key in URI_QUERIES:
|
446
493
|
_, raw_resp = await self._session_request(
|
447
494
|
"GET",
|
@@ -457,10 +504,21 @@ class AmazonEchoApi:
|
|
457
504
|
|
458
505
|
_LOGGER.debug("JSON data: |%s|", json_data)
|
459
506
|
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
507
|
+
for data in json_data[key]:
|
508
|
+
dev_serial = data.get("serialNumber") or data.get("deviceSerialNumber")
|
509
|
+
if previous_data := devices.get(dev_serial):
|
510
|
+
devices[dev_serial] = previous_data | {key: data}
|
511
|
+
else:
|
512
|
+
devices[dev_serial] = {key: data}
|
513
|
+
|
514
|
+
# Remove stale, orphaned and virtual devices
|
515
|
+
final_devices_list: dict[str, Any] = devices.copy()
|
516
|
+
for serial in devices:
|
517
|
+
device = devices[serial]
|
518
|
+
if (
|
519
|
+
DEVICES not in device
|
520
|
+
or device[DEVICES].get("deviceType") == AMAZON_DEVICE_TYPE
|
521
|
+
):
|
522
|
+
final_devices_list.pop(serial)
|
523
|
+
|
524
|
+
return final_devices_list
|
@@ -0,0 +1,315 @@
|
|
1
|
+
"""Custom authentication module for httpx."""
|
2
|
+
|
3
|
+
import base64
|
4
|
+
from collections.abc import Generator
|
5
|
+
from datetime import UTC, datetime, timedelta
|
6
|
+
from typing import (
|
7
|
+
Any,
|
8
|
+
cast,
|
9
|
+
)
|
10
|
+
|
11
|
+
import httpx
|
12
|
+
from httpx import Cookies
|
13
|
+
from pyasn1.codec.der import decoder
|
14
|
+
from pyasn1.type import namedtype, univ
|
15
|
+
from rsa import PrivateKey, pkcs1
|
16
|
+
|
17
|
+
from .const import _LOGGER, AMAZON_APP_NAME, AMAZON_APP_VERSION
|
18
|
+
from .exceptions import (
|
19
|
+
AuthFlowError,
|
20
|
+
AuthMissingAccessToken,
|
21
|
+
AuthMissingRefreshToken,
|
22
|
+
AuthMissingSigningData,
|
23
|
+
AuthMissingTimestamp,
|
24
|
+
AuthMissingWebsiteCookies,
|
25
|
+
)
|
26
|
+
|
27
|
+
|
28
|
+
def base64_der_to_pkcs1(base64_key: str) -> str:
|
29
|
+
"""Convert DER private key to PEM format."""
|
30
|
+
|
31
|
+
class PrivateKeyAlgorithm(univ.Sequence): # type: ignore[misc]
|
32
|
+
component_type = namedtype.NamedTypes(
|
33
|
+
namedtype.NamedType("algorithm", univ.ObjectIdentifier()),
|
34
|
+
namedtype.NamedType("parameters", univ.Any()),
|
35
|
+
)
|
36
|
+
|
37
|
+
class PrivateKeyInfo(univ.Sequence): # type: ignore[misc]
|
38
|
+
component_type = namedtype.NamedTypes(
|
39
|
+
namedtype.NamedType("version", univ.Integer()),
|
40
|
+
namedtype.NamedType("pkalgo", PrivateKeyAlgorithm()),
|
41
|
+
namedtype.NamedType("key", univ.OctetString()),
|
42
|
+
)
|
43
|
+
|
44
|
+
encoded_key = base64.b64decode(base64_key)
|
45
|
+
(key_info, _) = decoder.decode(encoded_key, asn1Spec=PrivateKeyInfo())
|
46
|
+
key_octet_string = key_info.components[2]
|
47
|
+
key = PrivateKey.load_pkcs1(key_octet_string, format="DER")
|
48
|
+
return cast(str, key.save_pkcs1().decode("utf-8"))
|
49
|
+
|
50
|
+
|
51
|
+
def refresh_access_token(
|
52
|
+
refresh_token: str,
|
53
|
+
domain: str,
|
54
|
+
) -> dict[str, Any]:
|
55
|
+
"""Refresh an access token."""
|
56
|
+
body = {
|
57
|
+
"app_name": AMAZON_APP_NAME,
|
58
|
+
"app_version": AMAZON_APP_VERSION,
|
59
|
+
"source_token": refresh_token,
|
60
|
+
"requested_token_type": "access_token",
|
61
|
+
"source_token_type": "refresh_token",
|
62
|
+
}
|
63
|
+
|
64
|
+
resp = httpx.post(f"https://api.amazon.{domain}/auth/token", data=body)
|
65
|
+
resp.raise_for_status()
|
66
|
+
resp_dict = resp.json()
|
67
|
+
|
68
|
+
expires_in_sec = int(resp_dict["expires_in"])
|
69
|
+
expires = (datetime.now(UTC) + timedelta(seconds=expires_in_sec)).timestamp()
|
70
|
+
|
71
|
+
return {"access_token": resp_dict["access_token"], "expires": expires}
|
72
|
+
|
73
|
+
|
74
|
+
def refresh_website_cookies(
|
75
|
+
refresh_token: str,
|
76
|
+
domain: str,
|
77
|
+
) -> dict[str, str]:
|
78
|
+
"""Fetch website cookies for a specific domain."""
|
79
|
+
url = f"https://www.amazon.{domain}/ap/exchangetoken/cookies"
|
80
|
+
|
81
|
+
body = {
|
82
|
+
"app_name": AMAZON_APP_NAME,
|
83
|
+
"app_version": AMAZON_APP_VERSION,
|
84
|
+
"source_token": refresh_token,
|
85
|
+
"requested_token_type": "auth_cookies",
|
86
|
+
"source_token_type": "refresh_token",
|
87
|
+
"domain": f".amazon.{domain}",
|
88
|
+
}
|
89
|
+
|
90
|
+
resp = httpx.post(url, data=body)
|
91
|
+
resp.raise_for_status()
|
92
|
+
resp_dict = resp.json()
|
93
|
+
|
94
|
+
raw_cookies = resp_dict["response"]["tokens"]["cookies"]
|
95
|
+
website_cookies = {}
|
96
|
+
for domain_cookies in raw_cookies:
|
97
|
+
for cookie in raw_cookies[domain_cookies]:
|
98
|
+
website_cookies[cookie["Name"]] = cookie["Value"].replace(r'"', r"")
|
99
|
+
|
100
|
+
return website_cookies
|
101
|
+
|
102
|
+
|
103
|
+
def user_profile(access_token: str, domain: str) -> dict[str, Any]:
|
104
|
+
"""Return Amazon user profile from Amazon."""
|
105
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
106
|
+
|
107
|
+
resp = httpx.get(f"https://api.amazon.{domain}/user/profile", headers=headers)
|
108
|
+
resp.raise_for_status()
|
109
|
+
profile: dict[str, Any] = resp.json()
|
110
|
+
|
111
|
+
if "user_id" not in profile:
|
112
|
+
raise ValueError("Malformed user profile response.")
|
113
|
+
|
114
|
+
return profile
|
115
|
+
|
116
|
+
|
117
|
+
def sign_request(
|
118
|
+
method: str,
|
119
|
+
path: str,
|
120
|
+
body: bytes,
|
121
|
+
adp_token: str,
|
122
|
+
private_key: str,
|
123
|
+
) -> dict[str, str]:
|
124
|
+
"""Create signed headers for http requests."""
|
125
|
+
date = datetime.now(UTC).isoformat("T") + "Z"
|
126
|
+
str_body = body.decode("utf-8")
|
127
|
+
|
128
|
+
data = f"{method}\n{path}\n{date}\n{str_body}\n{adp_token}"
|
129
|
+
|
130
|
+
key = PrivateKey.load_pkcs1(private_key.encode("utf-8"))
|
131
|
+
cipher = pkcs1.sign(data.encode(), key, "SHA-256")
|
132
|
+
signed_encoded = base64.b64encode(cipher)
|
133
|
+
|
134
|
+
signature = f"{signed_encoded.decode()}:{date}"
|
135
|
+
|
136
|
+
return {
|
137
|
+
"x-adp-token": adp_token,
|
138
|
+
"x-adp-alg": "SHA256withRSA:1.0",
|
139
|
+
"x-adp-signature": signature,
|
140
|
+
}
|
141
|
+
|
142
|
+
|
143
|
+
class Authenticator(httpx.Auth): # type: ignore[misc]
|
144
|
+
"""Amazon Authenticator class."""
|
145
|
+
|
146
|
+
access_token: str | None = None
|
147
|
+
activation_bytes: str | None = None
|
148
|
+
adp_token: str | None = None
|
149
|
+
customer_info: dict[str, Any] | None = None
|
150
|
+
device_info: dict[str, Any] | None = None
|
151
|
+
device_private_key: str | None = None
|
152
|
+
expires: float | None = None
|
153
|
+
domain: str
|
154
|
+
refresh_token: str | None = None
|
155
|
+
store_authentication_cookie: dict[str, Any] | None = None
|
156
|
+
website_cookies: dict[str, Any] | None = None
|
157
|
+
requires_request_body: bool = True
|
158
|
+
_forbid_new_attrs: bool = True
|
159
|
+
_apply_test_convert: bool = True
|
160
|
+
|
161
|
+
def update_attrs(self, **kwargs: Any) -> None: # noqa: ANN401
|
162
|
+
"""Update attributes from dict."""
|
163
|
+
for attr, value in kwargs.items():
|
164
|
+
setattr(self, attr, value)
|
165
|
+
|
166
|
+
@classmethod
|
167
|
+
def from_dict(
|
168
|
+
cls,
|
169
|
+
data: dict[str, Any],
|
170
|
+
domain: str,
|
171
|
+
) -> "Authenticator":
|
172
|
+
"""Instantiate an Authenticator from authentication dictionary."""
|
173
|
+
auth = cls()
|
174
|
+
|
175
|
+
auth.domain = domain
|
176
|
+
|
177
|
+
if "login_cookies" in data:
|
178
|
+
auth.website_cookies = data.pop("login_cookies")
|
179
|
+
|
180
|
+
if data["device_private_key"].startswith("MII"):
|
181
|
+
pk_der = data["device_private_key"]
|
182
|
+
data["device_private_key"] = base64_der_to_pkcs1(pk_der)
|
183
|
+
|
184
|
+
auth.update_attrs(**data)
|
185
|
+
|
186
|
+
_LOGGER.info("load data from dictionary for domain %s", auth.domain)
|
187
|
+
|
188
|
+
return auth
|
189
|
+
|
190
|
+
def auth_flow(
|
191
|
+
self,
|
192
|
+
request: httpx.Request,
|
193
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
194
|
+
"""Auth flow to be executed on every request by :mod:`httpx`."""
|
195
|
+
available_modes = self.available_auth_modes
|
196
|
+
|
197
|
+
_LOGGER.debug("Auth flow modes: %s", available_modes)
|
198
|
+
if "signing" in available_modes:
|
199
|
+
self._apply_signing_auth_flow(request)
|
200
|
+
elif "bearer" in available_modes:
|
201
|
+
self._apply_bearer_auth_flow(request)
|
202
|
+
else:
|
203
|
+
message = "signing or bearer auth flow are not available."
|
204
|
+
_LOGGER.critical(message)
|
205
|
+
raise AuthFlowError(message)
|
206
|
+
|
207
|
+
yield request
|
208
|
+
|
209
|
+
def _apply_signing_auth_flow(self, request: httpx.Request) -> None:
|
210
|
+
if self.adp_token is None or self.device_private_key is None:
|
211
|
+
raise AuthMissingSigningData
|
212
|
+
|
213
|
+
headers = sign_request(
|
214
|
+
method=request.method,
|
215
|
+
path=request.url.raw_path.decode(),
|
216
|
+
body=request.content,
|
217
|
+
adp_token=self.adp_token,
|
218
|
+
private_key=self.device_private_key,
|
219
|
+
)
|
220
|
+
|
221
|
+
request.headers.update(headers)
|
222
|
+
_LOGGER.info("signing auth flow applied to request")
|
223
|
+
|
224
|
+
def _apply_bearer_auth_flow(self, request: httpx.Request) -> None:
|
225
|
+
if self.access_token_expired:
|
226
|
+
self.refresh_access_token()
|
227
|
+
|
228
|
+
if self.access_token is None:
|
229
|
+
raise AuthMissingAccessToken
|
230
|
+
|
231
|
+
headers = {"Authorization": "Bearer " + self.access_token, "client-id": "0"}
|
232
|
+
request.headers.update(headers)
|
233
|
+
_LOGGER.info("bearer auth flow applied to request")
|
234
|
+
|
235
|
+
def _apply_cookies_auth_flow(self, request: httpx.Request) -> None:
|
236
|
+
if self.website_cookies is None:
|
237
|
+
raise AuthMissingWebsiteCookies
|
238
|
+
cookies = self.website_cookies.copy()
|
239
|
+
|
240
|
+
Cookies(cookies).set_cookie_header(request)
|
241
|
+
_LOGGER.info("cookies auth flow applied to request")
|
242
|
+
|
243
|
+
@property
|
244
|
+
def available_auth_modes(self) -> list[str]:
|
245
|
+
"""List available authentication modes."""
|
246
|
+
available_modes = []
|
247
|
+
|
248
|
+
if self.adp_token and self.device_private_key:
|
249
|
+
available_modes.append("signing")
|
250
|
+
|
251
|
+
if self.access_token:
|
252
|
+
if self.access_token_expired and not self.refresh_token:
|
253
|
+
pass
|
254
|
+
else:
|
255
|
+
available_modes.append("bearer")
|
256
|
+
|
257
|
+
if self.website_cookies:
|
258
|
+
available_modes.append("cookies")
|
259
|
+
|
260
|
+
return available_modes
|
261
|
+
|
262
|
+
def refresh_access_token(self, force: bool = False) -> None:
|
263
|
+
"""Refresh access token."""
|
264
|
+
if force or self.access_token_expired:
|
265
|
+
if self.refresh_token is None:
|
266
|
+
message = "No refresh token found. Can't refresh access token."
|
267
|
+
_LOGGER.critical(message)
|
268
|
+
raise AuthMissingRefreshToken(message)
|
269
|
+
|
270
|
+
refresh_data = refresh_access_token(
|
271
|
+
refresh_token=self.refresh_token,
|
272
|
+
domain=self.domain,
|
273
|
+
)
|
274
|
+
|
275
|
+
self.update_attrs(**refresh_data)
|
276
|
+
else:
|
277
|
+
_LOGGER.info(
|
278
|
+
"Access Token not expired. No refresh necessary. "
|
279
|
+
"To force refresh please use force=True",
|
280
|
+
)
|
281
|
+
|
282
|
+
def set_website_cookies_for_country(self) -> None:
|
283
|
+
"""Set website cookies for country."""
|
284
|
+
if self.refresh_token is None:
|
285
|
+
raise AuthMissingRefreshToken
|
286
|
+
|
287
|
+
self.website_cookies = refresh_website_cookies(
|
288
|
+
self.refresh_token,
|
289
|
+
self.domain,
|
290
|
+
)
|
291
|
+
|
292
|
+
def user_profile(self) -> dict[str, Any]:
|
293
|
+
"""Get user profile."""
|
294
|
+
if self.access_token is None:
|
295
|
+
raise AuthMissingAccessToken
|
296
|
+
|
297
|
+
return user_profile(access_token=self.access_token, domain=self.domain)
|
298
|
+
|
299
|
+
@property
|
300
|
+
def access_token_expires(self) -> timedelta:
|
301
|
+
"""Time to access token expiration."""
|
302
|
+
if self.expires is None:
|
303
|
+
raise AuthMissingTimestamp
|
304
|
+
return datetime.fromtimestamp(self.expires, UTC) - datetime.now(
|
305
|
+
UTC,
|
306
|
+
)
|
307
|
+
|
308
|
+
@property
|
309
|
+
def access_token_expired(self) -> bool:
|
310
|
+
"""Return True if access token is expired."""
|
311
|
+
if self.expires is None:
|
312
|
+
raise AuthMissingTimestamp
|
313
|
+
return datetime.fromtimestamp(self.expires, UTC) <= datetime.now(
|
314
|
+
UTC,
|
315
|
+
)
|
@@ -26,32 +26,32 @@ DOMAIN_BY_COUNTRY = {
|
|
26
26
|
},
|
27
27
|
}
|
28
28
|
|
29
|
+
# Amazon APP info
|
30
|
+
AMAZON_APP_BUNDLE_ID = "com.amazon.echo"
|
31
|
+
AMAZON_APP_ID = "MAPiOSLib/6.0/ToHideRetailLink"
|
32
|
+
AMAZON_APP_NAME = "AioAmazonDevices"
|
33
|
+
AMAZON_APP_VERSION = "2.2.556530.0"
|
34
|
+
AMAZON_DEVICE_SOFTWARE_VERSION = "35602678"
|
35
|
+
AMAZON_DEVICE_TYPE = "A2IVLV5VM2W81"
|
36
|
+
AMAZON_CLIENT_OS = "16.6"
|
37
|
+
|
29
38
|
DEFAULT_HEADERS = {
|
30
39
|
"User-Agent": (
|
31
|
-
"Mozilla/5.0 (iPhone; CPU iPhone OS
|
40
|
+
f"Mozilla/5.0 (iPhone; CPU iPhone OS {AMAZON_CLIENT_OS.replace('.', '_')} like Mac OS X) " # noqa: E501
|
32
41
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
|
33
42
|
),
|
34
43
|
"Accept-Language": "en-US",
|
35
44
|
"Accept-Encoding": "gzip",
|
36
45
|
}
|
37
46
|
|
47
|
+
DEVICES = "devices"
|
38
48
|
URI_QUERIES = {
|
39
|
-
|
40
|
-
"
|
41
|
-
"
|
42
|
-
"
|
43
|
-
"bluetooth": "/api/bluetooth",
|
49
|
+
DEVICES: "/api/devices-v2/device",
|
50
|
+
"doNotDisturbDeviceStatusList": "/api/dnd/device-status-list",
|
51
|
+
"devicePreferences": "/api/device-preferences",
|
52
|
+
"bluetoothStates": "/api/bluetooth",
|
44
53
|
}
|
45
54
|
|
46
|
-
# Amazon APP info
|
47
|
-
AMAZON_APP_BUNDLE_ID = "com.amazon.echo"
|
48
|
-
AMAZON_APP_ID = "MAPiOSLib/6.0/ToHideRetailLink"
|
49
|
-
AMAZON_APP_NAME = "AioAmazonDevices"
|
50
|
-
AMAZON_APP_VERSION = "2.2.556530.0"
|
51
|
-
AMAZON_DEVICE_SOFTWARE_VERSION = "35602678"
|
52
|
-
AMAZON_DEVICE_TYPE = "A2IVLV5VM2W81"
|
53
|
-
AMAZON_CLIENT_OS = "16.6"
|
54
|
-
|
55
55
|
# File extensions
|
56
56
|
SAVE_PATH = "out"
|
57
57
|
HTML_EXTENSION = ".html"
|
@@ -0,0 +1,51 @@
|
|
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."""
|
20
|
+
|
21
|
+
|
22
|
+
class CannotRegisterDevice(AmazonError):
|
23
|
+
"""Exception raised when device registration fails."""
|
24
|
+
|
25
|
+
|
26
|
+
class WrongMethod(AmazonError):
|
27
|
+
"""Exception raised when the wrong login metho is used."""
|
28
|
+
|
29
|
+
|
30
|
+
class AuthFlowError(AmazonError):
|
31
|
+
"""Exception raised when auth flow fails."""
|
32
|
+
|
33
|
+
|
34
|
+
class AuthMissingTimestamp(AmazonError):
|
35
|
+
"""Exception raised when expires timestamp is missing."""
|
36
|
+
|
37
|
+
|
38
|
+
class AuthMissingAccessToken(AmazonError):
|
39
|
+
"""Exception raised when access token is missing."""
|
40
|
+
|
41
|
+
|
42
|
+
class AuthMissingRefreshToken(AmazonError):
|
43
|
+
"""Exception raised when refresh token is missing."""
|
44
|
+
|
45
|
+
|
46
|
+
class AuthMissingSigningData(AmazonError):
|
47
|
+
"""Exception raised when some data for signing are missing."""
|
48
|
+
|
49
|
+
|
50
|
+
class AuthMissingWebsiteCookies(AmazonError):
|
51
|
+
"""Exception raised when website cookies are missing."""
|
@@ -1,23 +0,0 @@
|
|
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."""
|
20
|
-
|
21
|
-
|
22
|
-
class CannotRegisterDevice(AmazonError):
|
23
|
-
"""Exception raised when device registration fails."""
|
File without changes
|
File without changes
|
File without changes
|