aioamazondevices 6.5.1__py3-none-any.whl → 9.0.2__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.
- aioamazondevices/__init__.py +1 -1
- aioamazondevices/api.py +133 -833
- aioamazondevices/const/__init__.py +1 -0
- aioamazondevices/{const.py → const/devices.py} +45 -161
- aioamazondevices/const/http.py +36 -0
- aioamazondevices/const/metadata.py +44 -0
- aioamazondevices/{query.py → const/queries.py} +1 -1
- aioamazondevices/const/schedules.py +61 -0
- aioamazondevices/{sounds.py → const/sounds.py} +2 -1
- aioamazondevices/http_wrapper.py +349 -0
- aioamazondevices/login.py +445 -0
- aioamazondevices/structures.py +65 -0
- aioamazondevices/utils.py +23 -1
- {aioamazondevices-6.5.1.dist-info → aioamazondevices-9.0.2.dist-info}/METADATA +17 -4
- aioamazondevices-9.0.2.dist-info/RECORD +19 -0
- aioamazondevices-6.5.1.dist-info/RECORD +0 -12
- {aioamazondevices-6.5.1.dist-info → aioamazondevices-9.0.2.dist-info}/WHEEL +0 -0
- {aioamazondevices-6.5.1.dist-info → aioamazondevices-9.0.2.dist-info}/licenses/LICENSE +0 -0
aioamazondevices/api.py
CHANGED
|
@@ -1,143 +1,55 @@
|
|
|
1
1
|
"""Support for Amazon devices."""
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import base64
|
|
5
|
-
import hashlib
|
|
6
|
-
import mimetypes
|
|
7
|
-
import secrets
|
|
8
|
-
import uuid
|
|
9
|
-
from dataclasses import dataclass
|
|
3
|
+
from collections.abc import Callable, Coroutine
|
|
10
4
|
from datetime import UTC, datetime, timedelta
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from http.cookies import Morsel
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import Any, cast
|
|
16
|
-
from urllib.parse import parse_qs, urlencode
|
|
5
|
+
from http import HTTPMethod
|
|
6
|
+
from typing import Any
|
|
17
7
|
|
|
18
8
|
import orjson
|
|
19
|
-
from aiohttp import
|
|
20
|
-
ClientConnectorError,
|
|
21
|
-
ClientResponse,
|
|
22
|
-
ClientSession,
|
|
23
|
-
ContentTypeError,
|
|
24
|
-
)
|
|
25
|
-
from bs4 import BeautifulSoup, Tag
|
|
9
|
+
from aiohttp import ClientSession
|
|
26
10
|
from dateutil.parser import parse
|
|
27
11
|
from dateutil.rrule import rrulestr
|
|
28
|
-
from langcodes import Language, standardize_tag
|
|
29
|
-
from multidict import MultiDictProxy
|
|
30
|
-
from yarl import URL
|
|
31
12
|
|
|
32
13
|
from . import __version__
|
|
33
|
-
from .const import (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
AMAZON_APP_VERSION,
|
|
40
|
-
AMAZON_CLIENT_OS,
|
|
41
|
-
AMAZON_DEVICE_SOFTWARE_VERSION,
|
|
14
|
+
from .const.devices import (
|
|
15
|
+
DEVICE_TO_IGNORE,
|
|
16
|
+
DEVICE_TYPE_TO_MODEL,
|
|
17
|
+
SPEAKER_GROUP_FAMILY,
|
|
18
|
+
)
|
|
19
|
+
from .const.http import (
|
|
42
20
|
AMAZON_DEVICE_TYPE,
|
|
43
21
|
ARRAY_WRAPPER,
|
|
44
|
-
BIN_EXTENSION,
|
|
45
|
-
COUNTRY_GROUPS,
|
|
46
|
-
CSRF_COOKIE,
|
|
47
|
-
DEFAULT_HEADERS,
|
|
48
22
|
DEFAULT_SITE,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
23
|
+
URI_DEVICES,
|
|
24
|
+
URI_DND,
|
|
25
|
+
URI_NEXUS_GRAPHQL,
|
|
26
|
+
URI_NOTIFICATIONS,
|
|
27
|
+
)
|
|
28
|
+
from .const.metadata import ALEXA_INFO_SKILLS, SENSORS
|
|
29
|
+
from .const.queries import QUERY_DEVICE_DATA, QUERY_SENSOR_STATE
|
|
30
|
+
from .const.schedules import (
|
|
31
|
+
COUNTRY_GROUPS,
|
|
55
32
|
NOTIFICATION_ALARM,
|
|
56
33
|
NOTIFICATION_MUSIC_ALARM,
|
|
57
34
|
NOTIFICATION_REMINDER,
|
|
58
35
|
NOTIFICATION_TIMER,
|
|
36
|
+
NOTIFICATIONS_SUPPORTED,
|
|
59
37
|
RECURRING_PATTERNS,
|
|
60
|
-
REFRESH_ACCESS_TOKEN,
|
|
61
|
-
REFRESH_AUTH_COOKIES,
|
|
62
|
-
REQUEST_AGENT,
|
|
63
|
-
SAVE_PATH,
|
|
64
|
-
SENSORS,
|
|
65
|
-
URI_DEVICES,
|
|
66
|
-
URI_DND,
|
|
67
|
-
URI_NEXUS_GRAPHQL,
|
|
68
|
-
URI_NOTIFICATIONS,
|
|
69
|
-
URI_SIGNIN,
|
|
70
38
|
WEEKEND_EXCEPTIONS,
|
|
71
39
|
)
|
|
72
40
|
from .exceptions import (
|
|
73
|
-
CannotAuthenticate,
|
|
74
|
-
CannotConnect,
|
|
75
|
-
CannotRegisterDevice,
|
|
76
41
|
CannotRetrieveData,
|
|
77
|
-
WrongMethod,
|
|
78
42
|
)
|
|
79
|
-
from .
|
|
80
|
-
from .
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
error: bool
|
|
90
|
-
error_type: str | None
|
|
91
|
-
error_msg: str | None
|
|
92
|
-
scale: str | None
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
@dataclass
|
|
96
|
-
class AmazonSchedule:
|
|
97
|
-
"""Amazon schedule class."""
|
|
98
|
-
|
|
99
|
-
type: str # alarm, reminder, timer
|
|
100
|
-
status: str
|
|
101
|
-
label: str
|
|
102
|
-
next_occurrence: datetime | None
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
@dataclass
|
|
106
|
-
class AmazonDevice:
|
|
107
|
-
"""Amazon device class."""
|
|
108
|
-
|
|
109
|
-
account_name: str
|
|
110
|
-
capabilities: list[str]
|
|
111
|
-
device_family: str
|
|
112
|
-
device_type: str
|
|
113
|
-
device_owner_customer_id: str
|
|
114
|
-
household_device: bool
|
|
115
|
-
device_cluster_members: list[str]
|
|
116
|
-
online: bool
|
|
117
|
-
serial_number: str
|
|
118
|
-
software_version: str
|
|
119
|
-
entity_id: str | None
|
|
120
|
-
endpoint_id: str | None
|
|
121
|
-
sensors: dict[str, AmazonDeviceSensor]
|
|
122
|
-
notifications: dict[str, AmazonSchedule]
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
class AmazonSequenceType(StrEnum):
|
|
126
|
-
"""Amazon sequence types."""
|
|
127
|
-
|
|
128
|
-
Announcement = "AlexaAnnouncement"
|
|
129
|
-
Speak = "Alexa.Speak"
|
|
130
|
-
Sound = "Alexa.Sound"
|
|
131
|
-
Music = "Alexa.Music.PlaySearchPhrase"
|
|
132
|
-
TextCommand = "Alexa.TextCommand"
|
|
133
|
-
LaunchSkill = "Alexa.Operation.SkillConnections.Launch"
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
class AmazonMusicSource(StrEnum):
|
|
137
|
-
"""Amazon music sources."""
|
|
138
|
-
|
|
139
|
-
Radio = "TUNEIN"
|
|
140
|
-
AmazonMusic = "AMAZON_MUSIC"
|
|
43
|
+
from .http_wrapper import AmazonHttpWrapper, AmazonSessionStateData
|
|
44
|
+
from .login import AmazonLogin
|
|
45
|
+
from .structures import (
|
|
46
|
+
AmazonDevice,
|
|
47
|
+
AmazonDeviceSensor,
|
|
48
|
+
AmazonMusicSource,
|
|
49
|
+
AmazonSchedule,
|
|
50
|
+
AmazonSequenceType,
|
|
51
|
+
)
|
|
52
|
+
from .utils import _LOGGER
|
|
141
53
|
|
|
142
54
|
|
|
143
55
|
class AmazonEchoApi:
|
|
@@ -149,24 +61,34 @@ class AmazonEchoApi:
|
|
|
149
61
|
login_email: str,
|
|
150
62
|
login_password: str,
|
|
151
63
|
login_data: dict[str, Any] | None = None,
|
|
64
|
+
save_to_file: Callable[[str | dict, str, str], Coroutine[Any, Any, None]]
|
|
65
|
+
| None = None,
|
|
152
66
|
) -> None:
|
|
153
67
|
"""Initialize the scanner."""
|
|
68
|
+
_LOGGER.debug("Initialize library v%s", __version__)
|
|
69
|
+
|
|
154
70
|
# Check if there is a previous login, otherwise use default (US)
|
|
155
71
|
site = login_data.get("site", DEFAULT_SITE) if login_data else DEFAULT_SITE
|
|
156
72
|
_LOGGER.debug("Using site: %s", site)
|
|
157
|
-
self._country_specific_data(site)
|
|
158
73
|
|
|
159
|
-
self.
|
|
160
|
-
|
|
74
|
+
self._session_state_data = AmazonSessionStateData(
|
|
75
|
+
site, login_email, login_password, login_data
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
self._http_wrapper = AmazonHttpWrapper(
|
|
79
|
+
client_session,
|
|
80
|
+
self._session_state_data,
|
|
81
|
+
save_to_file,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self._login = AmazonLogin(
|
|
85
|
+
http_wrapper=self._http_wrapper,
|
|
86
|
+
session_state_data=self._session_state_data,
|
|
87
|
+
)
|
|
161
88
|
|
|
162
|
-
self._cookies = self._build_init_cookies()
|
|
163
|
-
self._save_raw_data = False
|
|
164
|
-
self._login_stored_data = login_data or {}
|
|
165
|
-
self._serial = self._serial_number()
|
|
166
89
|
self._account_owner_customer_id: str | None = None
|
|
167
90
|
self._list_for_clusters: dict[str, str] = {}
|
|
168
91
|
|
|
169
|
-
self._session = client_session
|
|
170
92
|
self._final_devices: dict[str, AmazonDevice] = {}
|
|
171
93
|
self._endpoints: dict[str, str] = {} # endpoint ID to serial number map
|
|
172
94
|
|
|
@@ -174,443 +96,23 @@ class AmazonEchoApi:
|
|
|
174
96
|
self._last_devices_refresh: datetime = initial_time
|
|
175
97
|
self._last_endpoint_refresh: datetime = initial_time
|
|
176
98
|
|
|
177
|
-
_LOGGER.debug("Initialize library v%s", __version__)
|
|
178
|
-
|
|
179
99
|
@property
|
|
180
100
|
def domain(self) -> str:
|
|
181
101
|
"""Return current Amazon domain."""
|
|
182
|
-
return self.
|
|
183
|
-
|
|
184
|
-
def save_raw_data(self) -> None:
|
|
185
|
-
"""Save raw data to disk."""
|
|
186
|
-
self._save_raw_data = True
|
|
187
|
-
_LOGGER.debug("Saving raw data to disk")
|
|
188
|
-
|
|
189
|
-
def _country_specific_data(self, domain: str) -> None:
|
|
190
|
-
"""Set country specific data."""
|
|
191
|
-
# Force lower case
|
|
192
|
-
domain = domain.replace("https://www.amazon.", "").lower()
|
|
193
|
-
country_code = domain.split(".")[-1] if domain != "com" else "us"
|
|
194
|
-
|
|
195
|
-
lang_object = Language.make(territory=country_code.upper())
|
|
196
|
-
lang_maximized = lang_object.maximize()
|
|
197
|
-
|
|
198
|
-
self._country_code: str = country_code
|
|
199
|
-
self._domain: str = domain
|
|
200
|
-
language = f"{lang_maximized.language}-{lang_maximized.territory}"
|
|
201
|
-
self._language = standardize_tag(language)
|
|
202
|
-
|
|
203
|
-
# Reset CSRF cookie when changing country
|
|
204
|
-
self._csrf_cookie: str | None = None
|
|
205
|
-
|
|
206
|
-
_LOGGER.debug(
|
|
207
|
-
"Initialize country <%s>: domain <amazon.%s>, language <%s>",
|
|
208
|
-
country_code.upper(),
|
|
209
|
-
self._domain,
|
|
210
|
-
self._language,
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
def _load_website_cookies(self) -> dict[str, str]:
|
|
214
|
-
"""Get website cookies, if avaliables."""
|
|
215
|
-
if not self._login_stored_data:
|
|
216
|
-
return {}
|
|
217
|
-
|
|
218
|
-
website_cookies: dict[str, Any] = self._login_stored_data["website_cookies"]
|
|
219
|
-
website_cookies.update(
|
|
220
|
-
{
|
|
221
|
-
"session-token": self._login_stored_data["store_authentication_cookie"][
|
|
222
|
-
"cookie"
|
|
223
|
-
]
|
|
224
|
-
}
|
|
225
|
-
)
|
|
226
|
-
website_cookies.update({"lc-acbit": self._language})
|
|
227
|
-
|
|
228
|
-
return website_cookies
|
|
229
|
-
|
|
230
|
-
def _serial_number(self) -> str:
|
|
231
|
-
"""Get or calculate device serial number."""
|
|
232
|
-
if not self._login_stored_data:
|
|
233
|
-
# Create a new serial number
|
|
234
|
-
_LOGGER.debug("Cannot find previous login data, creating new serial number")
|
|
235
|
-
return uuid.uuid4().hex.upper()
|
|
236
|
-
|
|
237
|
-
_LOGGER.debug("Found previous login data, loading serial number")
|
|
238
|
-
return cast(
|
|
239
|
-
"str",
|
|
240
|
-
self._login_stored_data["device_info"]["device_serial_number"],
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
def _build_init_cookies(self) -> dict[str, str]:
|
|
244
|
-
"""Build initial cookies to prevent captcha in most cases."""
|
|
245
|
-
token_bytes = secrets.token_bytes(313)
|
|
246
|
-
frc = base64.b64encode(token_bytes).decode("ascii").rstrip("=")
|
|
247
|
-
|
|
248
|
-
map_md_dict = {
|
|
249
|
-
"device_user_dictionary": [],
|
|
250
|
-
"device_registration_data": {
|
|
251
|
-
"software_version": AMAZON_DEVICE_SOFTWARE_VERSION,
|
|
252
|
-
},
|
|
253
|
-
"app_identifier": {
|
|
254
|
-
"app_version": AMAZON_APP_VERSION,
|
|
255
|
-
"bundle_id": AMAZON_APP_BUNDLE_ID,
|
|
256
|
-
},
|
|
257
|
-
}
|
|
258
|
-
map_md_str = orjson.dumps(map_md_dict).decode("utf-8")
|
|
259
|
-
map_md = base64.b64encode(map_md_str.encode()).decode().rstrip("=")
|
|
260
|
-
|
|
261
|
-
return {"amzn-app-id": AMAZON_APP_ID, "frc": frc, "map-md": map_md}
|
|
262
|
-
|
|
263
|
-
def _create_code_verifier(self, length: int = 32) -> bytes:
|
|
264
|
-
"""Create code verifier."""
|
|
265
|
-
verifier = secrets.token_bytes(length)
|
|
266
|
-
return base64.urlsafe_b64encode(verifier).rstrip(b"=")
|
|
267
|
-
|
|
268
|
-
def _create_s256_code_challenge(self, verifier: bytes) -> bytes:
|
|
269
|
-
"""Create S256 code challenge."""
|
|
270
|
-
m = hashlib.sha256(verifier)
|
|
271
|
-
return base64.urlsafe_b64encode(m.digest()).rstrip(b"=")
|
|
272
|
-
|
|
273
|
-
def _build_client_id(self) -> str:
|
|
274
|
-
"""Build client ID."""
|
|
275
|
-
client_id = self._serial.encode() + b"#" + AMAZON_DEVICE_TYPE.encode("utf-8")
|
|
276
|
-
return client_id.hex()
|
|
277
|
-
|
|
278
|
-
def _build_oauth_url(
|
|
279
|
-
self,
|
|
280
|
-
code_verifier: bytes,
|
|
281
|
-
client_id: str,
|
|
282
|
-
) -> str:
|
|
283
|
-
"""Build the url to login to Amazon as a Mobile device."""
|
|
284
|
-
code_challenge = self._create_s256_code_challenge(code_verifier)
|
|
285
|
-
|
|
286
|
-
oauth_params = {
|
|
287
|
-
"openid.return_to": "https://www.amazon.com/ap/maplanding",
|
|
288
|
-
"openid.oa2.code_challenge_method": "S256",
|
|
289
|
-
"openid.assoc_handle": "amzn_dp_project_dee_ios",
|
|
290
|
-
"openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
|
|
291
|
-
"pageId": "amzn_dp_project_dee_ios",
|
|
292
|
-
"accountStatusPolicy": "P1",
|
|
293
|
-
"openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
|
|
294
|
-
"openid.mode": "checkid_setup",
|
|
295
|
-
"openid.ns.oa2": "http://www.amazon.com/ap/ext/oauth/2",
|
|
296
|
-
"openid.oa2.client_id": f"device:{client_id}",
|
|
297
|
-
"language": self._language.replace("-", "_"),
|
|
298
|
-
"openid.ns.pape": "http://specs.openid.net/extensions/pape/1.0",
|
|
299
|
-
"openid.oa2.code_challenge": code_challenge,
|
|
300
|
-
"openid.oa2.scope": "device_auth_access",
|
|
301
|
-
"openid.ns": "http://specs.openid.net/auth/2.0",
|
|
302
|
-
"openid.pape.max_auth_age": "0",
|
|
303
|
-
"openid.oa2.response_type": "code",
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
return f"https://www.amazon.com{URI_SIGNIN}?{urlencode(oauth_params)}"
|
|
307
|
-
|
|
308
|
-
def _get_inputs_from_soup(self, soup: BeautifulSoup) -> dict[str, str]:
|
|
309
|
-
"""Extract hidden form input fields from a Amazon login page."""
|
|
310
|
-
form = soup.find("form", {"name": "signIn"}) or soup.find("form")
|
|
311
|
-
|
|
312
|
-
if not isinstance(form, Tag):
|
|
313
|
-
raise CannotAuthenticate("Unable to find form in login response")
|
|
314
|
-
|
|
315
|
-
inputs = {}
|
|
316
|
-
for field in form.find_all("input"):
|
|
317
|
-
if isinstance(field, Tag) and field.get("type", "") == "hidden":
|
|
318
|
-
inputs[field["name"]] = field.get("value", "")
|
|
319
|
-
|
|
320
|
-
return inputs
|
|
321
|
-
|
|
322
|
-
def _get_request_from_soup(self, soup: BeautifulSoup) -> tuple[str, str]:
|
|
323
|
-
"""Extract URL and method for the next request."""
|
|
324
|
-
_LOGGER.debug("Get request data from HTML source")
|
|
325
|
-
form = soup.find("form", {"name": "signIn"}) or soup.find("form")
|
|
326
|
-
if isinstance(form, Tag):
|
|
327
|
-
method = form.get("method")
|
|
328
|
-
url = form.get("action")
|
|
329
|
-
if isinstance(method, str) and isinstance(url, str):
|
|
330
|
-
return method, url
|
|
331
|
-
raise CannotAuthenticate("Unable to extract form data from response")
|
|
332
|
-
|
|
333
|
-
def _extract_code_from_url(self, url: URL) -> str:
|
|
334
|
-
"""Extract the access token from url query after login."""
|
|
335
|
-
parsed_url: dict[str, list[str]] = {}
|
|
336
|
-
if isinstance(url.query, bytes):
|
|
337
|
-
parsed_url = parse_qs(url.query.decode())
|
|
338
|
-
elif isinstance(url.query, MultiDictProxy):
|
|
339
|
-
for key, value in url.query.items():
|
|
340
|
-
parsed_url[key] = [value]
|
|
341
|
-
else:
|
|
342
|
-
raise CannotAuthenticate(
|
|
343
|
-
f"Unable to extract authorization code from url: {url}"
|
|
344
|
-
)
|
|
345
|
-
return parsed_url["openid.oa2.authorization_code"][0]
|
|
346
|
-
|
|
347
|
-
async def _ignore_ap_signin_error(self, response: ClientResponse) -> bool:
|
|
348
|
-
"""Return true if error is due to signin endpoint."""
|
|
349
|
-
# Endpoint URI_SIGNIN replies with error 404
|
|
350
|
-
# but reports the needed parameters anyway
|
|
351
|
-
if history := response.history:
|
|
352
|
-
return (
|
|
353
|
-
response.status == HTTPStatus.NOT_FOUND
|
|
354
|
-
and URI_SIGNIN in history[0].request_info.url.path
|
|
355
|
-
)
|
|
356
|
-
return False
|
|
357
|
-
|
|
358
|
-
async def _http_phrase_error(self, error: int) -> str:
|
|
359
|
-
"""Convert numeric error in human phrase."""
|
|
360
|
-
if error == HTTP_ERROR_199:
|
|
361
|
-
return "Miscellaneous Warning"
|
|
362
|
-
|
|
363
|
-
if error == HTTP_ERROR_299:
|
|
364
|
-
return "Miscellaneous Persistent Warning"
|
|
365
|
-
|
|
366
|
-
return HTTPStatus(error).phrase
|
|
367
|
-
|
|
368
|
-
async def _session_request(
|
|
369
|
-
self,
|
|
370
|
-
method: str,
|
|
371
|
-
url: str,
|
|
372
|
-
input_data: dict[str, Any] | list[dict[str, Any]] | None = None,
|
|
373
|
-
json_data: bool = False,
|
|
374
|
-
agent: str = "Amazon",
|
|
375
|
-
) -> tuple[BeautifulSoup, ClientResponse]:
|
|
376
|
-
"""Return request response context data."""
|
|
377
|
-
_LOGGER.debug(
|
|
378
|
-
"%s request: %s with payload %s [json=%s]",
|
|
379
|
-
method,
|
|
380
|
-
url,
|
|
381
|
-
scrub_fields(input_data) if input_data else None,
|
|
382
|
-
json_data,
|
|
383
|
-
)
|
|
384
|
-
|
|
385
|
-
headers = DEFAULT_HEADERS.copy()
|
|
386
|
-
headers.update({"User-Agent": REQUEST_AGENT[agent]})
|
|
387
|
-
headers.update({"Accept-Language": self._language})
|
|
388
|
-
|
|
389
|
-
if self._csrf_cookie:
|
|
390
|
-
csrf = {CSRF_COOKIE: self._csrf_cookie}
|
|
391
|
-
_LOGGER.debug("Adding to headers: %s", csrf)
|
|
392
|
-
headers.update(csrf)
|
|
393
|
-
|
|
394
|
-
if json_data:
|
|
395
|
-
json_header = {"Content-Type": "application/json; charset=utf-8"}
|
|
396
|
-
_LOGGER.debug("Adding to headers: %s", json_header)
|
|
397
|
-
headers.update(json_header)
|
|
398
|
-
|
|
399
|
-
_cookies = (
|
|
400
|
-
self._load_website_cookies() if self._login_stored_data else self._cookies
|
|
401
|
-
)
|
|
402
|
-
self._session.cookie_jar.update_cookies(_cookies, URL(f"amazon.{self._domain}"))
|
|
403
|
-
|
|
404
|
-
resp: ClientResponse | None = None
|
|
405
|
-
for delay in [0, 1, 2, 5, 8, 12, 21]:
|
|
406
|
-
if delay:
|
|
407
|
-
_LOGGER.info(
|
|
408
|
-
"Sleeping for %s seconds before retrying API call to %s", delay, url
|
|
409
|
-
)
|
|
410
|
-
await asyncio.sleep(delay)
|
|
411
|
-
|
|
412
|
-
try:
|
|
413
|
-
resp = await self._session.request(
|
|
414
|
-
method,
|
|
415
|
-
URL(url, encoded=True),
|
|
416
|
-
data=input_data if not json_data else orjson.dumps(input_data),
|
|
417
|
-
headers=headers,
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
except (TimeoutError, ClientConnectorError) as exc:
|
|
421
|
-
_LOGGER.warning("Connection error to %s: %s", url, repr(exc))
|
|
422
|
-
raise CannotConnect(f"Connection error during {method}") from exc
|
|
423
|
-
|
|
424
|
-
# Retry with a delay only for specific HTTP status
|
|
425
|
-
# that can benefits of a back-off
|
|
426
|
-
if resp.status not in [
|
|
427
|
-
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
428
|
-
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
429
|
-
HTTPStatus.TOO_MANY_REQUESTS,
|
|
430
|
-
]:
|
|
431
|
-
break
|
|
432
|
-
|
|
433
|
-
if resp is None:
|
|
434
|
-
_LOGGER.error("No response received from %s", url)
|
|
435
|
-
raise CannotConnect(f"No response received from {url}")
|
|
436
|
-
|
|
437
|
-
if not self._csrf_cookie and (
|
|
438
|
-
csrf := resp.cookies.get(CSRF_COOKIE, Morsel()).value
|
|
439
|
-
):
|
|
440
|
-
self._csrf_cookie = csrf
|
|
441
|
-
_LOGGER.debug("CSRF cookie value: <%s> [%s]", self._csrf_cookie, url)
|
|
442
|
-
|
|
443
|
-
content_type: str = resp.headers.get("Content-Type", "")
|
|
444
|
-
_LOGGER.debug(
|
|
445
|
-
"Response for url %s :\nstatus : %s \
|
|
446
|
-
\ncontent type: %s ",
|
|
447
|
-
url,
|
|
448
|
-
resp.status,
|
|
449
|
-
content_type,
|
|
450
|
-
)
|
|
451
|
-
|
|
452
|
-
if resp.status != HTTPStatus.OK:
|
|
453
|
-
if resp.status in [
|
|
454
|
-
HTTPStatus.FORBIDDEN,
|
|
455
|
-
HTTPStatus.PROXY_AUTHENTICATION_REQUIRED,
|
|
456
|
-
HTTPStatus.UNAUTHORIZED,
|
|
457
|
-
]:
|
|
458
|
-
raise CannotAuthenticate(await self._http_phrase_error(resp.status))
|
|
459
|
-
if not await self._ignore_ap_signin_error(resp):
|
|
460
|
-
raise CannotRetrieveData(
|
|
461
|
-
f"Request failed: {await self._http_phrase_error(resp.status)}"
|
|
462
|
-
)
|
|
463
|
-
|
|
464
|
-
await self._save_to_file(
|
|
465
|
-
await resp.text(),
|
|
466
|
-
url,
|
|
467
|
-
mimetypes.guess_extension(content_type.split(";")[0]) or ".raw",
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
return BeautifulSoup(await resp.read() or "", "html.parser"), resp
|
|
471
|
-
|
|
472
|
-
async def _save_to_file(
|
|
473
|
-
self,
|
|
474
|
-
raw_data: str | dict,
|
|
475
|
-
url: str,
|
|
476
|
-
extension: str = HTML_EXTENSION,
|
|
477
|
-
output_path: str = SAVE_PATH,
|
|
478
|
-
) -> None:
|
|
479
|
-
"""Save response data to disk."""
|
|
480
|
-
if not self._save_raw_data or not raw_data:
|
|
481
|
-
return
|
|
482
|
-
|
|
483
|
-
output_dir = Path(output_path)
|
|
484
|
-
output_dir.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
return self._session_state_data.domain
|
|
485
103
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
base_filename = url
|
|
491
|
-
fullpath = Path(output_dir, base_filename + extension)
|
|
492
|
-
|
|
493
|
-
data: str
|
|
494
|
-
if isinstance(raw_data, dict):
|
|
495
|
-
data = orjson.dumps(raw_data, option=orjson.OPT_INDENT_2).decode("utf-8")
|
|
496
|
-
elif extension in [HTML_EXTENSION, BIN_EXTENSION]:
|
|
497
|
-
data = raw_data
|
|
498
|
-
else:
|
|
499
|
-
data = orjson.dumps(
|
|
500
|
-
orjson.loads(raw_data),
|
|
501
|
-
option=orjson.OPT_INDENT_2,
|
|
502
|
-
).decode("utf-8")
|
|
503
|
-
|
|
504
|
-
i = 2
|
|
505
|
-
while fullpath.exists():
|
|
506
|
-
filename = f"{base_filename}_{i!s}{extension}"
|
|
507
|
-
fullpath = Path(output_dir, filename)
|
|
508
|
-
i += 1
|
|
509
|
-
|
|
510
|
-
_LOGGER.warning("Saving data to %s", fullpath)
|
|
511
|
-
|
|
512
|
-
with Path.open(fullpath, mode="w", encoding="utf-8") as file:
|
|
513
|
-
file.write(data)
|
|
514
|
-
file.write("\n")
|
|
515
|
-
|
|
516
|
-
async def _register_device(
|
|
517
|
-
self,
|
|
518
|
-
data: dict[str, Any],
|
|
519
|
-
) -> dict[str, Any]:
|
|
520
|
-
"""Register a dummy Alexa device."""
|
|
521
|
-
authorization_code: str = data["authorization_code"]
|
|
522
|
-
code_verifier: bytes = data["code_verifier"]
|
|
523
|
-
|
|
524
|
-
body = {
|
|
525
|
-
"requested_extensions": ["device_info", "customer_info"],
|
|
526
|
-
"cookies": {"website_cookies": [], "domain": f".amazon.{self._domain}"},
|
|
527
|
-
"registration_data": {
|
|
528
|
-
"domain": "Device",
|
|
529
|
-
"app_version": AMAZON_APP_VERSION,
|
|
530
|
-
"device_type": AMAZON_DEVICE_TYPE,
|
|
531
|
-
"device_name": (
|
|
532
|
-
f"%FIRST_NAME%\u0027s%DUPE_STRATEGY_1ST%{AMAZON_APP_NAME}"
|
|
533
|
-
),
|
|
534
|
-
"os_version": AMAZON_CLIENT_OS,
|
|
535
|
-
"device_serial": self._serial,
|
|
536
|
-
"device_model": "iPhone",
|
|
537
|
-
"app_name": AMAZON_APP_NAME,
|
|
538
|
-
"software_version": AMAZON_DEVICE_SOFTWARE_VERSION,
|
|
539
|
-
},
|
|
540
|
-
"auth_data": {
|
|
541
|
-
"use_global_authentication": "true",
|
|
542
|
-
"client_id": self._build_client_id(),
|
|
543
|
-
"authorization_code": authorization_code,
|
|
544
|
-
"code_verifier": code_verifier.decode(),
|
|
545
|
-
"code_algorithm": "SHA-256",
|
|
546
|
-
"client_domain": "DeviceLegacy",
|
|
547
|
-
},
|
|
548
|
-
"user_context_map": {"frc": self._cookies["frc"]},
|
|
549
|
-
"requested_token_type": [
|
|
550
|
-
"bearer",
|
|
551
|
-
"mac_dms",
|
|
552
|
-
"website_cookies",
|
|
553
|
-
"store_authentication_cookie",
|
|
554
|
-
],
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
register_url = "https://api.amazon.com/auth/register"
|
|
558
|
-
_, raw_resp = await self._session_request(
|
|
559
|
-
method=HTTPMethod.POST,
|
|
560
|
-
url=register_url,
|
|
561
|
-
input_data=body,
|
|
562
|
-
json_data=True,
|
|
563
|
-
)
|
|
564
|
-
resp_json = await self._response_to_json(raw_resp)
|
|
565
|
-
|
|
566
|
-
if raw_resp.status != HTTPStatus.OK:
|
|
567
|
-
msg = resp_json["response"]["error"]["message"]
|
|
568
|
-
_LOGGER.error(
|
|
569
|
-
"Cannot register device for %s: %s",
|
|
570
|
-
obfuscate_email(self._login_email),
|
|
571
|
-
msg,
|
|
572
|
-
)
|
|
573
|
-
raise CannotRegisterDevice(
|
|
574
|
-
f"{await self._http_phrase_error(raw_resp.status)}: {msg}"
|
|
575
|
-
)
|
|
576
|
-
|
|
577
|
-
success_response = resp_json["response"]["success"]
|
|
578
|
-
|
|
579
|
-
tokens = success_response["tokens"]
|
|
580
|
-
adp_token = tokens["mac_dms"]["adp_token"]
|
|
581
|
-
device_private_key = tokens["mac_dms"]["device_private_key"]
|
|
582
|
-
store_authentication_cookie = tokens["store_authentication_cookie"]
|
|
583
|
-
access_token = tokens["bearer"]["access_token"]
|
|
584
|
-
refresh_token = tokens["bearer"]["refresh_token"]
|
|
585
|
-
expires_s = int(tokens["bearer"]["expires_in"])
|
|
586
|
-
expires = (datetime.now(UTC) + timedelta(seconds=expires_s)).timestamp()
|
|
587
|
-
|
|
588
|
-
extensions = success_response["extensions"]
|
|
589
|
-
device_info = extensions["device_info"]
|
|
590
|
-
customer_info = extensions["customer_info"]
|
|
591
|
-
|
|
592
|
-
website_cookies = {}
|
|
593
|
-
for cookie in tokens["website_cookies"]:
|
|
594
|
-
website_cookies[cookie["Name"]] = cookie["Value"].replace(r'"', r"")
|
|
595
|
-
|
|
596
|
-
login_data = {
|
|
597
|
-
"adp_token": adp_token,
|
|
598
|
-
"device_private_key": device_private_key,
|
|
599
|
-
"access_token": access_token,
|
|
600
|
-
"refresh_token": refresh_token,
|
|
601
|
-
"expires": expires,
|
|
602
|
-
"website_cookies": website_cookies,
|
|
603
|
-
"store_authentication_cookie": store_authentication_cookie,
|
|
604
|
-
"device_info": device_info,
|
|
605
|
-
"customer_info": customer_info,
|
|
606
|
-
}
|
|
607
|
-
_LOGGER.info("Register device: %s", scrub_fields(login_data))
|
|
608
|
-
return login_data
|
|
104
|
+
@property
|
|
105
|
+
def login(self) -> AmazonLogin:
|
|
106
|
+
"""Return login."""
|
|
107
|
+
return self._login
|
|
609
108
|
|
|
610
109
|
async def _get_sensors_states(self) -> dict[str, dict[str, AmazonDeviceSensor]]:
|
|
611
110
|
"""Retrieve devices sensors states."""
|
|
612
111
|
devices_sensors: dict[str, dict[str, AmazonDeviceSensor]] = {}
|
|
613
112
|
|
|
113
|
+
if not self._endpoints:
|
|
114
|
+
return {}
|
|
115
|
+
|
|
614
116
|
endpoint_ids = list(self._endpoints.keys())
|
|
615
117
|
payload = [
|
|
616
118
|
{
|
|
@@ -622,15 +124,14 @@ class AmazonEchoApi:
|
|
|
622
124
|
}
|
|
623
125
|
]
|
|
624
126
|
|
|
625
|
-
_, raw_resp = await self.
|
|
127
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
626
128
|
method=HTTPMethod.POST,
|
|
627
|
-
url=f"https://alexa.amazon.{self.
|
|
129
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NEXUS_GRAPHQL}",
|
|
628
130
|
input_data=payload,
|
|
629
131
|
json_data=True,
|
|
630
132
|
)
|
|
631
133
|
|
|
632
|
-
sensors_state = await self.
|
|
633
|
-
_LOGGER.debug("Sensor data - %s", sensors_state)
|
|
134
|
+
sensors_state = await self._http_wrapper.response_to_json(raw_resp, "sensors")
|
|
634
135
|
|
|
635
136
|
if await self._format_human_error(sensors_state):
|
|
636
137
|
# Explicit error in returned data
|
|
@@ -712,14 +213,16 @@ class AmazonEchoApi:
|
|
|
712
213
|
_LOGGER.debug(
|
|
713
214
|
"error in sensor %s - %s - %s", name, error_type, error_msg
|
|
714
215
|
)
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
216
|
+
|
|
217
|
+
if error_type != "NOT_FOUND":
|
|
218
|
+
device_sensors[name] = AmazonDeviceSensor(
|
|
219
|
+
name,
|
|
220
|
+
value,
|
|
221
|
+
error,
|
|
222
|
+
error_type,
|
|
223
|
+
error_msg,
|
|
224
|
+
scale,
|
|
225
|
+
)
|
|
723
226
|
|
|
724
227
|
return device_sensors
|
|
725
228
|
|
|
@@ -730,14 +233,14 @@ class AmazonEchoApi:
|
|
|
730
233
|
"query": QUERY_DEVICE_DATA,
|
|
731
234
|
}
|
|
732
235
|
|
|
733
|
-
_, raw_resp = await self.
|
|
236
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
734
237
|
method=HTTPMethod.POST,
|
|
735
|
-
url=f"https://alexa.amazon.{self.
|
|
238
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NEXUS_GRAPHQL}",
|
|
736
239
|
input_data=payload,
|
|
737
240
|
json_data=True,
|
|
738
241
|
)
|
|
739
242
|
|
|
740
|
-
endpoint_data = await self.
|
|
243
|
+
endpoint_data = await self._http_wrapper.response_to_json(raw_resp, "endpoint")
|
|
741
244
|
|
|
742
245
|
if not (data := endpoint_data.get("data")) or not data.get("listEndpoints"):
|
|
743
246
|
await self._format_human_error(endpoint_data)
|
|
@@ -757,34 +260,34 @@ class AmazonEchoApi:
|
|
|
757
260
|
|
|
758
261
|
return devices_endpoints
|
|
759
262
|
|
|
760
|
-
async def _response_to_json(self, raw_resp: ClientResponse) -> dict[str, Any]:
|
|
761
|
-
"""Convert response to JSON, if possible."""
|
|
762
|
-
try:
|
|
763
|
-
data = await raw_resp.json(loads=orjson.loads)
|
|
764
|
-
if not data:
|
|
765
|
-
_LOGGER.warning("Empty JSON data received")
|
|
766
|
-
data = {}
|
|
767
|
-
if isinstance(data, list):
|
|
768
|
-
# if anonymous array is returned wrap it inside
|
|
769
|
-
# generated key to convert list to dict
|
|
770
|
-
data = {ARRAY_WRAPPER: data}
|
|
771
|
-
return cast("dict[str, Any]", data)
|
|
772
|
-
except ContentTypeError as exc:
|
|
773
|
-
raise ValueError("Response not in JSON format") from exc
|
|
774
|
-
except orjson.JSONDecodeError as exc:
|
|
775
|
-
raise ValueError("Response with corrupted JSON format") from exc
|
|
776
|
-
|
|
777
263
|
async def _get_notifications(self) -> dict[str, dict[str, AmazonSchedule]]:
|
|
778
264
|
final_notifications: dict[str, dict[str, AmazonSchedule]] = {}
|
|
779
265
|
|
|
780
|
-
_, raw_resp = await self.
|
|
266
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
781
267
|
HTTPMethod.GET,
|
|
782
|
-
url=f"https://alexa.amazon.{self.
|
|
268
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NOTIFICATIONS}",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
notifications = await self._http_wrapper.response_to_json(
|
|
272
|
+
raw_resp, "notifications"
|
|
783
273
|
)
|
|
784
|
-
|
|
274
|
+
|
|
785
275
|
for schedule in notifications["notifications"]:
|
|
786
276
|
schedule_type: str = schedule["type"]
|
|
277
|
+
schedule_device_type = schedule["deviceType"]
|
|
787
278
|
schedule_device_serial = schedule["deviceSerialNumber"]
|
|
279
|
+
|
|
280
|
+
if schedule_device_type in DEVICE_TO_IGNORE:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
if schedule_type not in NOTIFICATIONS_SUPPORTED:
|
|
284
|
+
_LOGGER.debug(
|
|
285
|
+
"Unsupported schedule type %s for device %s",
|
|
286
|
+
schedule_type,
|
|
287
|
+
schedule_device_serial,
|
|
288
|
+
)
|
|
289
|
+
continue
|
|
290
|
+
|
|
788
291
|
if schedule_type == NOTIFICATION_MUSIC_ALARM:
|
|
789
292
|
# Structure is the same as standard Alarm
|
|
790
293
|
schedule_type = NOTIFICATION_ALARM
|
|
@@ -866,13 +369,17 @@ class AmazonEchoApi:
|
|
|
866
369
|
continue
|
|
867
370
|
|
|
868
371
|
if recurring_rule not in RECURRING_PATTERNS:
|
|
869
|
-
_LOGGER.warning(
|
|
372
|
+
_LOGGER.warning(
|
|
373
|
+
"Unknown recurring rule <%s> for schedule type <%s>",
|
|
374
|
+
recurring_rule,
|
|
375
|
+
schedule["type"],
|
|
376
|
+
)
|
|
870
377
|
return None
|
|
871
378
|
|
|
872
379
|
# Adjust recurring rules for country specific weekend exceptions
|
|
873
380
|
recurring_pattern = RECURRING_PATTERNS.copy()
|
|
874
381
|
for group, countries in COUNTRY_GROUPS.items():
|
|
875
|
-
if self.
|
|
382
|
+
if self._session_state_data.country_code in countries:
|
|
876
383
|
recurring_pattern |= WEEKEND_EXCEPTIONS[group]
|
|
877
384
|
break
|
|
878
385
|
|
|
@@ -931,147 +438,6 @@ class AmazonEchoApi:
|
|
|
931
438
|
|
|
932
439
|
return rule
|
|
933
440
|
|
|
934
|
-
async def login_mode_interactive(self, otp_code: str) -> dict[str, Any]:
|
|
935
|
-
"""Login to Amazon interactively via OTP."""
|
|
936
|
-
_LOGGER.debug(
|
|
937
|
-
"Logging-in for %s [otp code: %s]",
|
|
938
|
-
obfuscate_email(self._login_email),
|
|
939
|
-
bool(otp_code),
|
|
940
|
-
)
|
|
941
|
-
|
|
942
|
-
device_login_data = await self._login_mode_interactive_oauth(otp_code)
|
|
943
|
-
|
|
944
|
-
login_data = await self._register_device(device_login_data)
|
|
945
|
-
self._login_stored_data = login_data
|
|
946
|
-
|
|
947
|
-
await self._domain_refresh_auth_cookies()
|
|
948
|
-
|
|
949
|
-
self._login_stored_data.update({"site": f"https://www.amazon.{self._domain}"})
|
|
950
|
-
await self._save_to_file(self._login_stored_data, "login_data", JSON_EXTENSION)
|
|
951
|
-
|
|
952
|
-
# Can take a little while to register device but we need it
|
|
953
|
-
# to be able to pickout account customer ID
|
|
954
|
-
await asyncio.sleep(2)
|
|
955
|
-
|
|
956
|
-
return self._login_stored_data
|
|
957
|
-
|
|
958
|
-
async def _login_mode_interactive_oauth(
|
|
959
|
-
self, otp_code: str
|
|
960
|
-
) -> dict[str, str | bytes]:
|
|
961
|
-
"""Login interactive via oauth URL."""
|
|
962
|
-
code_verifier = self._create_code_verifier()
|
|
963
|
-
client_id = self._build_client_id()
|
|
964
|
-
|
|
965
|
-
_LOGGER.debug("Build oauth URL")
|
|
966
|
-
login_url = self._build_oauth_url(code_verifier, client_id)
|
|
967
|
-
|
|
968
|
-
login_soup, _ = await self._session_request(
|
|
969
|
-
method=HTTPMethod.GET, url=login_url
|
|
970
|
-
)
|
|
971
|
-
login_method, login_url = self._get_request_from_soup(login_soup)
|
|
972
|
-
login_inputs = self._get_inputs_from_soup(login_soup)
|
|
973
|
-
login_inputs["email"] = self._login_email
|
|
974
|
-
login_inputs["password"] = self._login_password
|
|
975
|
-
|
|
976
|
-
_LOGGER.debug("Register at %s", login_url)
|
|
977
|
-
login_soup, _ = await self._session_request(
|
|
978
|
-
method=login_method,
|
|
979
|
-
url=login_url,
|
|
980
|
-
input_data=login_inputs,
|
|
981
|
-
)
|
|
982
|
-
|
|
983
|
-
if not login_soup.find("input", id="auth-mfa-otpcode"):
|
|
984
|
-
_LOGGER.debug(
|
|
985
|
-
'Cannot find "auth-mfa-otpcode" in html source [%s]', login_url
|
|
986
|
-
)
|
|
987
|
-
raise CannotAuthenticate("MFA OTP code not found on login page")
|
|
988
|
-
|
|
989
|
-
login_method, login_url = self._get_request_from_soup(login_soup)
|
|
990
|
-
|
|
991
|
-
login_inputs = self._get_inputs_from_soup(login_soup)
|
|
992
|
-
login_inputs["otpCode"] = otp_code
|
|
993
|
-
login_inputs["mfaSubmit"] = "Submit"
|
|
994
|
-
login_inputs["rememberDevice"] = "false"
|
|
995
|
-
|
|
996
|
-
login_soup, login_resp = await self._session_request(
|
|
997
|
-
method=login_method,
|
|
998
|
-
url=login_url,
|
|
999
|
-
input_data=login_inputs,
|
|
1000
|
-
)
|
|
1001
|
-
_LOGGER.debug("Login response url:%s", login_resp.url)
|
|
1002
|
-
|
|
1003
|
-
authcode = self._extract_code_from_url(login_resp.url)
|
|
1004
|
-
_LOGGER.debug("Login extracted authcode: %s", authcode)
|
|
1005
|
-
|
|
1006
|
-
return {
|
|
1007
|
-
"authorization_code": authcode,
|
|
1008
|
-
"code_verifier": code_verifier,
|
|
1009
|
-
"domain": self._domain,
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
async def login_mode_stored_data(self) -> dict[str, Any]:
|
|
1013
|
-
"""Login to Amazon using previously stored data."""
|
|
1014
|
-
if not self._login_stored_data:
|
|
1015
|
-
_LOGGER.debug(
|
|
1016
|
-
"Cannot find previous login data,\
|
|
1017
|
-
use login_mode_interactive() method instead",
|
|
1018
|
-
)
|
|
1019
|
-
raise WrongMethod
|
|
1020
|
-
|
|
1021
|
-
_LOGGER.debug(
|
|
1022
|
-
"Logging-in for %s with stored data",
|
|
1023
|
-
obfuscate_email(self._login_email),
|
|
1024
|
-
)
|
|
1025
|
-
|
|
1026
|
-
# Check if session is still authenticated
|
|
1027
|
-
if not await self.auth_check_status():
|
|
1028
|
-
raise CannotAuthenticate("Session no longer authenticated")
|
|
1029
|
-
|
|
1030
|
-
return self._login_stored_data
|
|
1031
|
-
|
|
1032
|
-
async def _get_alexa_domain(self) -> str:
|
|
1033
|
-
"""Get the Alexa domain."""
|
|
1034
|
-
_LOGGER.debug("Retrieve Alexa domain")
|
|
1035
|
-
_, raw_resp = await self._session_request(
|
|
1036
|
-
method=HTTPMethod.GET,
|
|
1037
|
-
url=f"https://alexa.amazon.{self._domain}/api/welcome",
|
|
1038
|
-
)
|
|
1039
|
-
json_data = await self._response_to_json(raw_resp)
|
|
1040
|
-
return cast(
|
|
1041
|
-
"str", json_data.get("alexaHostName", f"alexa.amazon.{self._domain}")
|
|
1042
|
-
)
|
|
1043
|
-
|
|
1044
|
-
async def _refresh_auth_cookies(self) -> None:
|
|
1045
|
-
"""Refresh cookies after domain swap."""
|
|
1046
|
-
_, json_token_resp = await self._refresh_data(REFRESH_AUTH_COOKIES)
|
|
1047
|
-
|
|
1048
|
-
# Need to take cookies from response and create them as cookies
|
|
1049
|
-
website_cookies = self._login_stored_data["website_cookies"] = {}
|
|
1050
|
-
self._session.cookie_jar.clear()
|
|
1051
|
-
|
|
1052
|
-
cookie_json = json_token_resp["response"]["tokens"]["cookies"]
|
|
1053
|
-
for cookie_domain in cookie_json:
|
|
1054
|
-
for cookie in cookie_json[cookie_domain]:
|
|
1055
|
-
new_cookie_value = cookie["Value"].replace(r'"', r"")
|
|
1056
|
-
new_cookie = {cookie["Name"]: new_cookie_value}
|
|
1057
|
-
self._session.cookie_jar.update_cookies(new_cookie, URL(cookie_domain))
|
|
1058
|
-
website_cookies.update(new_cookie)
|
|
1059
|
-
if cookie["Name"] == "session-token":
|
|
1060
|
-
self._login_stored_data["store_authentication_cookie"] = {
|
|
1061
|
-
"cookie": new_cookie_value
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
async def _domain_refresh_auth_cookies(self) -> None:
|
|
1065
|
-
"""Refresh cookies after domain swap."""
|
|
1066
|
-
_LOGGER.debug("Refreshing auth cookies after domain change")
|
|
1067
|
-
|
|
1068
|
-
# Get the new Alexa domain
|
|
1069
|
-
user_domain = (await self._get_alexa_domain()).replace("alexa", "https://www")
|
|
1070
|
-
if user_domain != DEFAULT_SITE:
|
|
1071
|
-
_LOGGER.debug("User domain changed to %s", user_domain)
|
|
1072
|
-
self._country_specific_data(user_domain)
|
|
1073
|
-
await self._refresh_auth_cookies()
|
|
1074
|
-
|
|
1075
441
|
async def _get_account_owner_customer_id(self, data: dict[str, Any]) -> str | None:
|
|
1076
442
|
"""Get account owner customer ID."""
|
|
1077
443
|
if data["deviceType"] != AMAZON_DEVICE_TYPE:
|
|
@@ -1079,7 +445,7 @@ class AmazonEchoApi:
|
|
|
1079
445
|
|
|
1080
446
|
account_owner_customer_id: str | None = None
|
|
1081
447
|
|
|
1082
|
-
this_device_serial = self.
|
|
448
|
+
this_device_serial = self._session_state_data.login_stored_data["device_info"][
|
|
1083
449
|
"device_serial_number"
|
|
1084
450
|
]
|
|
1085
451
|
|
|
@@ -1139,9 +505,14 @@ class AmazonEchoApi:
|
|
|
1139
505
|
else:
|
|
1140
506
|
for device_sensor in device.sensors.values():
|
|
1141
507
|
device_sensor.error = True
|
|
1142
|
-
if
|
|
508
|
+
if (
|
|
509
|
+
device_dnd := dnd_sensors.get(device.serial_number)
|
|
510
|
+
) and device.device_family != SPEAKER_GROUP_FAMILY:
|
|
1143
511
|
device.sensors["dnd"] = device_dnd
|
|
1144
512
|
|
|
513
|
+
# Clear old notifications to handle cancelled ones
|
|
514
|
+
device.notifications = {}
|
|
515
|
+
|
|
1145
516
|
# Update notifications
|
|
1146
517
|
device_notifications = notifications.get(device.serial_number, {})
|
|
1147
518
|
|
|
@@ -1178,14 +549,12 @@ class AmazonEchoApi:
|
|
|
1178
549
|
)
|
|
1179
550
|
|
|
1180
551
|
async def _get_base_devices(self) -> None:
|
|
1181
|
-
_, raw_resp = await self.
|
|
552
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
1182
553
|
method=HTTPMethod.GET,
|
|
1183
|
-
url=f"https://alexa.amazon.{self.
|
|
554
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DEVICES}",
|
|
1184
555
|
)
|
|
1185
556
|
|
|
1186
|
-
json_data = await self.
|
|
1187
|
-
|
|
1188
|
-
_LOGGER.debug("JSON devices data: %s", scrub_fields(json_data))
|
|
557
|
+
json_data = await self._http_wrapper.response_to_json(raw_resp, "devices")
|
|
1189
558
|
|
|
1190
559
|
for data in json_data["devices"]:
|
|
1191
560
|
dev_serial = data.get("serialNumber")
|
|
@@ -1208,11 +577,20 @@ class AmazonEchoApi:
|
|
|
1208
577
|
if not device or (device.get("deviceType") in DEVICE_TO_IGNORE):
|
|
1209
578
|
continue
|
|
1210
579
|
|
|
580
|
+
account_name: str = device["accountName"]
|
|
581
|
+
capabilities: list[str] = device["capabilities"]
|
|
582
|
+
# Skip devices that cannot be used with voice features
|
|
583
|
+
if "MICROPHONE" not in capabilities:
|
|
584
|
+
_LOGGER.debug(
|
|
585
|
+
"Skipping device without microphone capabilities: %s", account_name
|
|
586
|
+
)
|
|
587
|
+
continue
|
|
588
|
+
|
|
1211
589
|
serial_number: str = device["serialNumber"]
|
|
1212
590
|
|
|
1213
591
|
final_devices_list[serial_number] = AmazonDevice(
|
|
1214
|
-
account_name=
|
|
1215
|
-
capabilities=
|
|
592
|
+
account_name=account_name,
|
|
593
|
+
capabilities=capabilities,
|
|
1216
594
|
device_family=device["deviceFamily"],
|
|
1217
595
|
device_type=device["deviceType"],
|
|
1218
596
|
device_owner_customer_id=device["deviceOwnerCustomerId"],
|
|
@@ -1237,29 +615,6 @@ class AmazonEchoApi:
|
|
|
1237
615
|
|
|
1238
616
|
self._final_devices = final_devices_list
|
|
1239
617
|
|
|
1240
|
-
async def auth_check_status(self) -> bool:
|
|
1241
|
-
"""Check AUTH status."""
|
|
1242
|
-
_, raw_resp = await self._session_request(
|
|
1243
|
-
method=HTTPMethod.GET,
|
|
1244
|
-
url=f"https://alexa.amazon.{self._domain}/api/bootstrap?version=0",
|
|
1245
|
-
agent="Browser",
|
|
1246
|
-
)
|
|
1247
|
-
if raw_resp.status != HTTPStatus.OK:
|
|
1248
|
-
_LOGGER.debug(
|
|
1249
|
-
"Session not authenticated: reply error %s",
|
|
1250
|
-
raw_resp.status,
|
|
1251
|
-
)
|
|
1252
|
-
return False
|
|
1253
|
-
|
|
1254
|
-
resp_json = await self._response_to_json(raw_resp)
|
|
1255
|
-
if not (authentication := resp_json.get("authentication")):
|
|
1256
|
-
_LOGGER.debug('Session not authenticated: reply missing "authentication"')
|
|
1257
|
-
return False
|
|
1258
|
-
|
|
1259
|
-
authenticated = authentication.get("authenticated")
|
|
1260
|
-
_LOGGER.debug("Session authenticated: %s", authenticated)
|
|
1261
|
-
return bool(authenticated)
|
|
1262
|
-
|
|
1263
618
|
def get_model_details(self, device: AmazonDevice) -> dict[str, str | None] | None:
|
|
1264
619
|
"""Return model datails."""
|
|
1265
620
|
model_details: dict[str, str | None] | None = DEVICE_TYPE_TO_MODEL.get(
|
|
@@ -1282,14 +637,14 @@ class AmazonEchoApi:
|
|
|
1282
637
|
message_source: AmazonMusicSource | None = None,
|
|
1283
638
|
) -> None:
|
|
1284
639
|
"""Send message to specific device."""
|
|
1285
|
-
if not self.
|
|
640
|
+
if not self._session_state_data.login_stored_data:
|
|
1286
641
|
_LOGGER.warning("No login data available, cannot send message")
|
|
1287
642
|
return
|
|
1288
643
|
|
|
1289
644
|
base_payload = {
|
|
1290
645
|
"deviceType": device.device_type,
|
|
1291
646
|
"deviceSerialNumber": device.serial_number,
|
|
1292
|
-
"locale": self.
|
|
647
|
+
"locale": self._session_state_data.language,
|
|
1293
648
|
"customerId": self._account_owner_customer_id,
|
|
1294
649
|
}
|
|
1295
650
|
|
|
@@ -1324,7 +679,7 @@ class AmazonEchoApi:
|
|
|
1324
679
|
"expireAfter": "PT5S",
|
|
1325
680
|
"content": [
|
|
1326
681
|
{
|
|
1327
|
-
"locale": self.
|
|
682
|
+
"locale": self._session_state_data.language,
|
|
1328
683
|
"display": {
|
|
1329
684
|
"title": "Home Assistant",
|
|
1330
685
|
"body": message_body,
|
|
@@ -1399,9 +754,9 @@ class AmazonEchoApi:
|
|
|
1399
754
|
}
|
|
1400
755
|
|
|
1401
756
|
_LOGGER.debug("Preview data payload: %s", node_data)
|
|
1402
|
-
await self.
|
|
757
|
+
await self._http_wrapper.session_request(
|
|
1403
758
|
method=HTTPMethod.POST,
|
|
1404
|
-
url=f"https://alexa.amazon.{self.
|
|
759
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}/api/behaviors/preview",
|
|
1405
760
|
input_data=node_data,
|
|
1406
761
|
json_data=True,
|
|
1407
762
|
)
|
|
@@ -1480,77 +835,22 @@ class AmazonEchoApi:
|
|
|
1480
835
|
"deviceType": device.device_type,
|
|
1481
836
|
"enabled": state,
|
|
1482
837
|
}
|
|
1483
|
-
url = f"https://alexa.amazon.{self.
|
|
1484
|
-
await self.
|
|
1485
|
-
method="PUT",
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
"""Refresh data."""
|
|
1490
|
-
if not self._login_stored_data:
|
|
1491
|
-
_LOGGER.debug("No login data available, cannot refresh")
|
|
1492
|
-
return False, {}
|
|
1493
|
-
|
|
1494
|
-
data = {
|
|
1495
|
-
"app_name": AMAZON_APP_NAME,
|
|
1496
|
-
"app_version": AMAZON_APP_VERSION,
|
|
1497
|
-
"di.sdk.version": "6.12.4",
|
|
1498
|
-
"source_token": self._login_stored_data["refresh_token"],
|
|
1499
|
-
"package_name": AMAZON_APP_BUNDLE_ID,
|
|
1500
|
-
"di.hw.version": "iPhone",
|
|
1501
|
-
"platform": "iOS",
|
|
1502
|
-
"requested_token_type": data_type,
|
|
1503
|
-
"source_token_type": "refresh_token",
|
|
1504
|
-
"di.os.name": "iOS",
|
|
1505
|
-
"di.os.version": AMAZON_CLIENT_OS,
|
|
1506
|
-
"current_version": "6.12.4",
|
|
1507
|
-
"previous_version": "6.12.4",
|
|
1508
|
-
"domain": f"www.amazon.{self._domain}",
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
_, raw_resp = await self._session_request(
|
|
1512
|
-
HTTPMethod.POST,
|
|
1513
|
-
"https://api.amazon.com/auth/token",
|
|
1514
|
-
input_data=data,
|
|
1515
|
-
json_data=False,
|
|
1516
|
-
)
|
|
1517
|
-
_LOGGER.debug(
|
|
1518
|
-
"Refresh data response %s with payload %s",
|
|
1519
|
-
raw_resp.status,
|
|
1520
|
-
orjson.dumps(data),
|
|
838
|
+
url = f"https://alexa.amazon.{self._session_state_data.domain}/api/dnd/status"
|
|
839
|
+
await self._http_wrapper.session_request(
|
|
840
|
+
method="PUT",
|
|
841
|
+
url=url,
|
|
842
|
+
input_data=payload,
|
|
843
|
+
json_data=True,
|
|
1521
844
|
)
|
|
1522
845
|
|
|
1523
|
-
if raw_resp.status != HTTPStatus.OK:
|
|
1524
|
-
_LOGGER.debug("Failed to refresh data")
|
|
1525
|
-
return False, {}
|
|
1526
|
-
|
|
1527
|
-
json_response = await self._response_to_json(raw_resp)
|
|
1528
|
-
_LOGGER.debug("Refresh data json:\n%s ", json_response)
|
|
1529
|
-
|
|
1530
|
-
if data_type == REFRESH_ACCESS_TOKEN and (
|
|
1531
|
-
new_token := json_response.get(REFRESH_ACCESS_TOKEN)
|
|
1532
|
-
):
|
|
1533
|
-
self._login_stored_data[REFRESH_ACCESS_TOKEN] = new_token
|
|
1534
|
-
self.expires_in = datetime.now(tz=UTC).timestamp() + int(
|
|
1535
|
-
json_response.get("expires_in", 0)
|
|
1536
|
-
)
|
|
1537
|
-
return True, json_response
|
|
1538
|
-
|
|
1539
|
-
if data_type == REFRESH_AUTH_COOKIES:
|
|
1540
|
-
return True, json_response
|
|
1541
|
-
|
|
1542
|
-
_LOGGER.debug("Unexpected refresh data response")
|
|
1543
|
-
return False, {}
|
|
1544
|
-
|
|
1545
846
|
async def _get_dnd_status(self) -> dict[str, AmazonDeviceSensor]:
|
|
1546
847
|
dnd_status: dict[str, AmazonDeviceSensor] = {}
|
|
1547
|
-
_, raw_resp = await self.
|
|
848
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
1548
849
|
method=HTTPMethod.GET,
|
|
1549
|
-
url=f"https://alexa.amazon.{self.
|
|
850
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DND}",
|
|
1550
851
|
)
|
|
1551
852
|
|
|
1552
|
-
dnd_data = await self.
|
|
1553
|
-
_LOGGER.debug("DND data: %s", dnd_data)
|
|
853
|
+
dnd_data = await self._http_wrapper.response_to_json(raw_resp, "dnd")
|
|
1554
854
|
|
|
1555
855
|
for dnd in dnd_data.get("doNotDisturbDeviceStatusList", {}):
|
|
1556
856
|
dnd_status[dnd.get("deviceSerialNumber")] = AmazonDeviceSensor(
|