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/api.py CHANGED
@@ -1,143 +1,55 @@
1
1
  """Support for Amazon devices."""
2
2
 
3
- import asyncio
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 enum import StrEnum
12
- from http import HTTPMethod, HTTPStatus
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
- _LOGGER,
35
- ALEXA_INFO_SKILLS,
36
- AMAZON_APP_BUNDLE_ID,
37
- AMAZON_APP_ID,
38
- AMAZON_APP_NAME,
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
- DEVICE_TO_IGNORE,
50
- DEVICE_TYPE_TO_MODEL,
51
- HTML_EXTENSION,
52
- HTTP_ERROR_199,
53
- HTTP_ERROR_299,
54
- JSON_EXTENSION,
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 .query import QUERY_DEVICE_DATA, QUERY_SENSOR_STATE
80
- from .utils import obfuscate_email, scrub_fields
81
-
82
-
83
- @dataclass
84
- class AmazonDeviceSensor:
85
- """Amazon device sensor class."""
86
-
87
- name: str
88
- value: str | int | float
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._login_email = login_email
160
- self._login_password = login_password
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._domain
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
- if url.startswith("http"):
487
- url_split = url.split("/")
488
- base_filename = f"{url_split[3]}-{url_split[4].split('?')[0]}"
489
- else:
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._session_request(
127
+ _, raw_resp = await self._http_wrapper.session_request(
626
128
  method=HTTPMethod.POST,
627
- url=f"https://alexa.amazon.{self._domain}{URI_NEXUS_GRAPHQL}",
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._response_to_json(raw_resp)
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
- device_sensors[name] = AmazonDeviceSensor(
716
- name,
717
- value,
718
- error,
719
- error_type,
720
- error_msg,
721
- scale,
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._session_request(
236
+ _, raw_resp = await self._http_wrapper.session_request(
734
237
  method=HTTPMethod.POST,
735
- url=f"https://alexa.amazon.{self._domain}{URI_NEXUS_GRAPHQL}",
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._response_to_json(raw_resp)
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._session_request(
266
+ _, raw_resp = await self._http_wrapper.session_request(
781
267
  HTTPMethod.GET,
782
- url=f"https://alexa.amazon.{self._domain}{URI_NOTIFICATIONS}",
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
- notifications = await self._response_to_json(raw_resp)
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("Unknown recurring rule: %s", recurring_rule)
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._country_code in countries:
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._login_stored_data["device_info"][
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 device_dnd := dnd_sensors.get(device.serial_number):
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._session_request(
552
+ _, raw_resp = await self._http_wrapper.session_request(
1182
553
  method=HTTPMethod.GET,
1183
- url=f"https://alexa.amazon.{self._domain}{URI_DEVICES}",
554
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DEVICES}",
1184
555
  )
1185
556
 
1186
- json_data = await self._response_to_json(raw_resp)
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=device["accountName"],
1215
- capabilities=device["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._login_stored_data:
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._language,
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._language,
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._session_request(
757
+ await self._http_wrapper.session_request(
1403
758
  method=HTTPMethod.POST,
1404
- url=f"https://alexa.amazon.{self._domain}/api/behaviors/preview",
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._domain}/api/dnd/status"
1484
- await self._session_request(
1485
- method="PUT", url=url, input_data=payload, json_data=True
1486
- )
1487
-
1488
- async def _refresh_data(self, data_type: str) -> tuple[bool, dict]:
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._session_request(
848
+ _, raw_resp = await self._http_wrapper.session_request(
1548
849
  method=HTTPMethod.GET,
1549
- url=f"https://alexa.amazon.{self._domain}{URI_DND}",
850
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DND}",
1550
851
  )
1551
852
 
1552
- dnd_data = await self._response_to_json(raw_resp)
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(