aioamazondevices 6.4.5__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,121 +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
26
- from langcodes import Language, standardize_tag
27
- from multidict import MultiDictProxy
28
- from yarl import URL
9
+ from aiohttp import ClientSession
10
+ from dateutil.parser import parse
11
+ from dateutil.rrule import rrulestr
29
12
 
30
13
  from . import __version__
31
- from .const import (
32
- _LOGGER,
33
- ALEXA_INFO_SKILLS,
34
- AMAZON_APP_BUNDLE_ID,
35
- AMAZON_APP_ID,
36
- AMAZON_APP_NAME,
37
- AMAZON_APP_VERSION,
38
- AMAZON_CLIENT_OS,
39
- AMAZON_DEVICE_SOFTWARE_VERSION,
40
- AMAZON_DEVICE_TYPE,
41
- BIN_EXTENSION,
42
- CSRF_COOKIE,
43
- DEFAULT_HEADERS,
44
- DEFAULT_SITE,
14
+ from .const.devices import (
45
15
  DEVICE_TO_IGNORE,
46
16
  DEVICE_TYPE_TO_MODEL,
47
- HTML_EXTENSION,
48
- HTTP_ERROR_199,
49
- HTTP_ERROR_299,
50
- JSON_EXTENSION,
51
- REFRESH_ACCESS_TOKEN,
52
- REFRESH_AUTH_COOKIES,
53
- REQUEST_AGENT,
54
- SAVE_PATH,
55
- SENSORS,
17
+ SPEAKER_GROUP_FAMILY,
18
+ )
19
+ from .const.http import (
20
+ AMAZON_DEVICE_TYPE,
21
+ ARRAY_WRAPPER,
22
+ DEFAULT_SITE,
56
23
  URI_DEVICES,
57
24
  URI_DND,
58
25
  URI_NEXUS_GRAPHQL,
59
- URI_SIGNIN,
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,
32
+ NOTIFICATION_ALARM,
33
+ NOTIFICATION_MUSIC_ALARM,
34
+ NOTIFICATION_REMINDER,
35
+ NOTIFICATION_TIMER,
36
+ NOTIFICATIONS_SUPPORTED,
37
+ RECURRING_PATTERNS,
38
+ WEEKEND_EXCEPTIONS,
60
39
  )
61
40
  from .exceptions import (
62
- CannotAuthenticate,
63
- CannotConnect,
64
- CannotRegisterDevice,
65
41
  CannotRetrieveData,
66
- WrongMethod,
67
42
  )
68
- from .query import QUERY_DEVICE_DATA, QUERY_SENSOR_STATE
69
- from .utils import obfuscate_email, scrub_fields
70
-
71
-
72
- @dataclass
73
- class AmazonDeviceSensor:
74
- """Amazon device sensor class."""
75
-
76
- name: str
77
- value: str | int | float
78
- error: bool
79
- error_type: str | None
80
- error_msg: str | None
81
- scale: str | None
82
-
83
-
84
- @dataclass
85
- class AmazonDevice:
86
- """Amazon device class."""
87
-
88
- account_name: str
89
- capabilities: list[str]
90
- device_family: str
91
- device_type: str
92
- device_owner_customer_id: str
93
- household_device: bool
94
- device_cluster_members: list[str]
95
- online: bool
96
- serial_number: str
97
- software_version: str
98
- entity_id: str | None
99
- endpoint_id: str | None
100
- sensors: dict[str, AmazonDeviceSensor]
101
-
102
-
103
- class AmazonSequenceType(StrEnum):
104
- """Amazon sequence types."""
105
-
106
- Announcement = "AlexaAnnouncement"
107
- Speak = "Alexa.Speak"
108
- Sound = "Alexa.Sound"
109
- Music = "Alexa.Music.PlaySearchPhrase"
110
- TextCommand = "Alexa.TextCommand"
111
- LaunchSkill = "Alexa.Operation.SkillConnections.Launch"
112
-
113
-
114
- class AmazonMusicSource(StrEnum):
115
- """Amazon music sources."""
116
-
117
- Radio = "TUNEIN"
118
- 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
119
53
 
120
54
 
121
55
  class AmazonEchoApi:
@@ -127,24 +61,34 @@ class AmazonEchoApi:
127
61
  login_email: str,
128
62
  login_password: str,
129
63
  login_data: dict[str, Any] | None = None,
64
+ save_to_file: Callable[[str | dict, str, str], Coroutine[Any, Any, None]]
65
+ | None = None,
130
66
  ) -> None:
131
67
  """Initialize the scanner."""
68
+ _LOGGER.debug("Initialize library v%s", __version__)
69
+
132
70
  # Check if there is a previous login, otherwise use default (US)
133
71
  site = login_data.get("site", DEFAULT_SITE) if login_data else DEFAULT_SITE
134
72
  _LOGGER.debug("Using site: %s", site)
135
- self._country_specific_data(site)
136
73
 
137
- self._login_email = login_email
138
- 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
+ )
139
88
 
140
- self._cookies = self._build_init_cookies()
141
- self._save_raw_data = False
142
- self._login_stored_data = login_data or {}
143
- self._serial = self._serial_number()
144
89
  self._account_owner_customer_id: str | None = None
145
90
  self._list_for_clusters: dict[str, str] = {}
146
91
 
147
- self._session = client_session
148
92
  self._final_devices: dict[str, AmazonDevice] = {}
149
93
  self._endpoints: dict[str, str] = {} # endpoint ID to serial number map
150
94
 
@@ -152,502 +96,63 @@ class AmazonEchoApi:
152
96
  self._last_devices_refresh: datetime = initial_time
153
97
  self._last_endpoint_refresh: datetime = initial_time
154
98
 
155
- _LOGGER.debug("Initialize library v%s", __version__)
156
-
157
99
  @property
158
100
  def domain(self) -> str:
159
101
  """Return current Amazon domain."""
160
- return self._domain
161
-
162
- def save_raw_data(self) -> None:
163
- """Save raw data to disk."""
164
- self._save_raw_data = True
165
- _LOGGER.debug("Saving raw data to disk")
166
-
167
- def _country_specific_data(self, domain: str) -> None:
168
- """Set country specific data."""
169
- # Force lower case
170
- domain = domain.replace("https://www.amazon.", "").lower()
171
- country_code = domain.split(".")[-1] if domain != "com" else "us"
172
-
173
- lang_object = Language.make(territory=country_code.upper())
174
- lang_maximized = lang_object.maximize()
175
-
176
- self._domain: str = domain
177
- language = f"{lang_maximized.language}-{lang_maximized.territory}"
178
- self._language = standardize_tag(language)
179
-
180
- # Reset CSRF cookie when changing country
181
- self._csrf_cookie: str | None = None
182
-
183
- _LOGGER.debug(
184
- "Initialize country <%s>: domain <amazon.%s>, language <%s>",
185
- country_code.upper(),
186
- self._domain,
187
- self._language,
188
- )
189
-
190
- def _load_website_cookies(self) -> dict[str, str]:
191
- """Get website cookies, if avaliables."""
192
- if not self._login_stored_data:
193
- return {}
194
-
195
- website_cookies: dict[str, Any] = self._login_stored_data["website_cookies"]
196
- website_cookies.update(
197
- {
198
- "session-token": self._login_stored_data["store_authentication_cookie"][
199
- "cookie"
200
- ]
201
- }
202
- )
203
- website_cookies.update({"lc-acbit": self._language})
204
-
205
- return website_cookies
206
-
207
- def _serial_number(self) -> str:
208
- """Get or calculate device serial number."""
209
- if not self._login_stored_data:
210
- # Create a new serial number
211
- _LOGGER.debug("Cannot find previous login data, creating new serial number")
212
- return uuid.uuid4().hex.upper()
213
-
214
- _LOGGER.debug("Found previous login data, loading serial number")
215
- return cast(
216
- "str",
217
- self._login_stored_data["device_info"]["device_serial_number"],
218
- )
219
-
220
- def _build_init_cookies(self) -> dict[str, str]:
221
- """Build initial cookies to prevent captcha in most cases."""
222
- token_bytes = secrets.token_bytes(313)
223
- frc = base64.b64encode(token_bytes).decode("ascii").rstrip("=")
224
-
225
- map_md_dict = {
226
- "device_user_dictionary": [],
227
- "device_registration_data": {
228
- "software_version": AMAZON_DEVICE_SOFTWARE_VERSION,
229
- },
230
- "app_identifier": {
231
- "app_version": AMAZON_APP_VERSION,
232
- "bundle_id": AMAZON_APP_BUNDLE_ID,
233
- },
234
- }
235
- map_md_str = orjson.dumps(map_md_dict).decode("utf-8")
236
- map_md = base64.b64encode(map_md_str.encode()).decode().rstrip("=")
237
-
238
- return {"amzn-app-id": AMAZON_APP_ID, "frc": frc, "map-md": map_md}
239
-
240
- def _create_code_verifier(self, length: int = 32) -> bytes:
241
- """Create code verifier."""
242
- verifier = secrets.token_bytes(length)
243
- return base64.urlsafe_b64encode(verifier).rstrip(b"=")
244
-
245
- def _create_s256_code_challenge(self, verifier: bytes) -> bytes:
246
- """Create S256 code challenge."""
247
- m = hashlib.sha256(verifier)
248
- return base64.urlsafe_b64encode(m.digest()).rstrip(b"=")
249
-
250
- def _build_client_id(self) -> str:
251
- """Build client ID."""
252
- client_id = self._serial.encode() + b"#" + AMAZON_DEVICE_TYPE.encode("utf-8")
253
- return client_id.hex()
254
-
255
- def _build_oauth_url(
256
- self,
257
- code_verifier: bytes,
258
- client_id: str,
259
- ) -> str:
260
- """Build the url to login to Amazon as a Mobile device."""
261
- code_challenge = self._create_s256_code_challenge(code_verifier)
262
-
263
- oauth_params = {
264
- "openid.return_to": "https://www.amazon.com/ap/maplanding",
265
- "openid.oa2.code_challenge_method": "S256",
266
- "openid.assoc_handle": "amzn_dp_project_dee_ios",
267
- "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
268
- "pageId": "amzn_dp_project_dee_ios",
269
- "accountStatusPolicy": "P1",
270
- "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
271
- "openid.mode": "checkid_setup",
272
- "openid.ns.oa2": "http://www.amazon.com/ap/ext/oauth/2",
273
- "openid.oa2.client_id": f"device:{client_id}",
274
- "language": self._language.replace("-", "_"),
275
- "openid.ns.pape": "http://specs.openid.net/extensions/pape/1.0",
276
- "openid.oa2.code_challenge": code_challenge,
277
- "openid.oa2.scope": "device_auth_access",
278
- "openid.ns": "http://specs.openid.net/auth/2.0",
279
- "openid.pape.max_auth_age": "0",
280
- "openid.oa2.response_type": "code",
281
- }
102
+ return self._session_state_data.domain
282
103
 
283
- return f"https://www.amazon.com{URI_SIGNIN}?{urlencode(oauth_params)}"
284
-
285
- def _get_inputs_from_soup(self, soup: BeautifulSoup) -> dict[str, str]:
286
- """Extract hidden form input fields from a Amazon login page."""
287
- form = soup.find("form", {"name": "signIn"}) or soup.find("form")
288
-
289
- if not isinstance(form, Tag):
290
- raise CannotAuthenticate("Unable to find form in login response")
291
-
292
- inputs = {}
293
- for field in form.find_all("input"):
294
- if isinstance(field, Tag) and field.get("type", "") == "hidden":
295
- inputs[field["name"]] = field.get("value", "")
296
-
297
- return inputs
298
-
299
- def _get_request_from_soup(self, soup: BeautifulSoup) -> tuple[str, str]:
300
- """Extract URL and method for the next request."""
301
- _LOGGER.debug("Get request data from HTML source")
302
- form = soup.find("form", {"name": "signIn"}) or soup.find("form")
303
- if isinstance(form, Tag):
304
- method = form.get("method")
305
- url = form.get("action")
306
- if isinstance(method, str) and isinstance(url, str):
307
- return method, url
308
- raise CannotAuthenticate("Unable to extract form data from response")
309
-
310
- def _extract_code_from_url(self, url: URL) -> str:
311
- """Extract the access token from url query after login."""
312
- parsed_url: dict[str, list[str]] = {}
313
- if isinstance(url.query, bytes):
314
- parsed_url = parse_qs(url.query.decode())
315
- elif isinstance(url.query, MultiDictProxy):
316
- for key, value in url.query.items():
317
- parsed_url[key] = [value]
318
- else:
319
- raise CannotAuthenticate(
320
- f"Unable to extract authorization code from url: {url}"
321
- )
322
- return parsed_url["openid.oa2.authorization_code"][0]
323
-
324
- async def _ignore_ap_signin_error(self, response: ClientResponse) -> bool:
325
- """Return true if error is due to signin endpoint."""
326
- # Endpoint URI_SIGNIN replies with error 404
327
- # but reports the needed parameters anyway
328
- if history := response.history:
329
- return (
330
- response.status == HTTPStatus.NOT_FOUND
331
- and URI_SIGNIN in history[0].request_info.url.path
332
- )
333
- return False
334
-
335
- async def _http_phrase_error(self, error: int) -> str:
336
- """Convert numeric error in human phrase."""
337
- if error == HTTP_ERROR_199:
338
- return "Miscellaneous Warning"
339
-
340
- if error == HTTP_ERROR_299:
341
- return "Miscellaneous Persistent Warning"
342
-
343
- return HTTPStatus(error).phrase
344
-
345
- async def _session_request(
346
- self,
347
- method: str,
348
- url: str,
349
- input_data: dict[str, Any] | list[dict[str, Any]] | None = None,
350
- json_data: bool = False,
351
- agent: str = "Amazon",
352
- ) -> tuple[BeautifulSoup, ClientResponse]:
353
- """Return request response context data."""
354
- _LOGGER.debug(
355
- "%s request: %s with payload %s [json=%s]",
356
- method,
357
- url,
358
- scrub_fields(input_data) if input_data else None,
359
- json_data,
360
- )
361
-
362
- headers = DEFAULT_HEADERS.copy()
363
- headers.update({"User-Agent": REQUEST_AGENT[agent]})
364
- headers.update({"Accept-Language": self._language})
365
-
366
- if self._csrf_cookie:
367
- csrf = {CSRF_COOKIE: self._csrf_cookie}
368
- _LOGGER.debug("Adding to headers: %s", csrf)
369
- headers.update(csrf)
370
-
371
- if json_data:
372
- json_header = {"Content-Type": "application/json; charset=utf-8"}
373
- _LOGGER.debug("Adding to headers: %s", json_header)
374
- headers.update(json_header)
375
-
376
- _cookies = (
377
- self._load_website_cookies() if self._login_stored_data else self._cookies
378
- )
379
- self._session.cookie_jar.update_cookies(_cookies, URL(f"amazon.{self._domain}"))
380
-
381
- resp: ClientResponse | None = None
382
- for delay in [0, 1, 2, 5, 8, 12, 21]:
383
- if delay:
384
- _LOGGER.info(
385
- "Sleeping for %s seconds before retrying API call to %s", delay, url
386
- )
387
- await asyncio.sleep(delay)
388
-
389
- try:
390
- resp = await self._session.request(
391
- method,
392
- URL(url, encoded=True),
393
- data=input_data if not json_data else orjson.dumps(input_data),
394
- headers=headers,
395
- )
396
-
397
- except (TimeoutError, ClientConnectorError) as exc:
398
- _LOGGER.warning("Connection error to %s: %s", url, repr(exc))
399
- raise CannotConnect(f"Connection error during {method}") from exc
400
-
401
- # Retry with a delay only for specific HTTP status
402
- # that can benefits of a back-off
403
- if resp.status not in [
404
- HTTPStatus.INTERNAL_SERVER_ERROR,
405
- HTTPStatus.SERVICE_UNAVAILABLE,
406
- HTTPStatus.TOO_MANY_REQUESTS,
407
- ]:
408
- break
409
-
410
- if resp is None:
411
- _LOGGER.error("No response received from %s", url)
412
- raise CannotConnect(f"No response received from {url}")
413
-
414
- if not self._csrf_cookie and (
415
- csrf := resp.cookies.get(CSRF_COOKIE, Morsel()).value
416
- ):
417
- self._csrf_cookie = csrf
418
- _LOGGER.debug("CSRF cookie value: <%s> [%s]", self._csrf_cookie, url)
419
-
420
- content_type: str = resp.headers.get("Content-Type", "")
421
- _LOGGER.debug(
422
- "Response for url %s :\nstatus : %s \
423
- \ncontent type: %s ",
424
- url,
425
- resp.status,
426
- content_type,
427
- )
428
-
429
- if resp.status != HTTPStatus.OK:
430
- if resp.status in [
431
- HTTPStatus.FORBIDDEN,
432
- HTTPStatus.PROXY_AUTHENTICATION_REQUIRED,
433
- HTTPStatus.UNAUTHORIZED,
434
- ]:
435
- raise CannotAuthenticate(await self._http_phrase_error(resp.status))
436
- if not await self._ignore_ap_signin_error(resp):
437
- raise CannotRetrieveData(
438
- f"Request failed: {await self._http_phrase_error(resp.status)}"
439
- )
440
-
441
- await self._save_to_file(
442
- await resp.text(),
443
- url,
444
- mimetypes.guess_extension(content_type.split(";")[0]) or ".raw",
445
- )
446
-
447
- return BeautifulSoup(await resp.read() or "", "html.parser"), resp
448
-
449
- async def _save_to_file(
450
- self,
451
- raw_data: str | dict,
452
- url: str,
453
- extension: str = HTML_EXTENSION,
454
- output_path: str = SAVE_PATH,
455
- ) -> None:
456
- """Save response data to disk."""
457
- if not self._save_raw_data or not raw_data:
458
- return
459
-
460
- output_dir = Path(output_path)
461
- output_dir.mkdir(parents=True, exist_ok=True)
462
-
463
- if url.startswith("http"):
464
- url_split = url.split("/")
465
- base_filename = f"{url_split[3]}-{url_split[4].split('?')[0]}"
466
- else:
467
- base_filename = url
468
- fullpath = Path(output_dir, base_filename + extension)
469
-
470
- data: str
471
- if isinstance(raw_data, dict):
472
- data = orjson.dumps(raw_data, option=orjson.OPT_INDENT_2).decode("utf-8")
473
- elif extension in [HTML_EXTENSION, BIN_EXTENSION]:
474
- data = raw_data
475
- else:
476
- data = orjson.dumps(
477
- orjson.loads(raw_data),
478
- option=orjson.OPT_INDENT_2,
479
- ).decode("utf-8")
480
-
481
- i = 2
482
- while fullpath.exists():
483
- filename = f"{base_filename}_{i!s}{extension}"
484
- fullpath = Path(output_dir, filename)
485
- i += 1
486
-
487
- _LOGGER.warning("Saving data to %s", fullpath)
488
-
489
- with Path.open(fullpath, mode="w", encoding="utf-8") as file:
490
- file.write(data)
491
- file.write("\n")
492
-
493
- async def _register_device(
494
- self,
495
- data: dict[str, Any],
496
- ) -> dict[str, Any]:
497
- """Register a dummy Alexa device."""
498
- authorization_code: str = data["authorization_code"]
499
- code_verifier: bytes = data["code_verifier"]
500
-
501
- body = {
502
- "requested_extensions": ["device_info", "customer_info"],
503
- "cookies": {"website_cookies": [], "domain": f".amazon.{self._domain}"},
504
- "registration_data": {
505
- "domain": "Device",
506
- "app_version": AMAZON_APP_VERSION,
507
- "device_type": AMAZON_DEVICE_TYPE,
508
- "device_name": (
509
- f"%FIRST_NAME%\u0027s%DUPE_STRATEGY_1ST%{AMAZON_APP_NAME}"
510
- ),
511
- "os_version": AMAZON_CLIENT_OS,
512
- "device_serial": self._serial,
513
- "device_model": "iPhone",
514
- "app_name": AMAZON_APP_NAME,
515
- "software_version": AMAZON_DEVICE_SOFTWARE_VERSION,
516
- },
517
- "auth_data": {
518
- "use_global_authentication": "true",
519
- "client_id": self._build_client_id(),
520
- "authorization_code": authorization_code,
521
- "code_verifier": code_verifier.decode(),
522
- "code_algorithm": "SHA-256",
523
- "client_domain": "DeviceLegacy",
524
- },
525
- "user_context_map": {"frc": self._cookies["frc"]},
526
- "requested_token_type": [
527
- "bearer",
528
- "mac_dms",
529
- "website_cookies",
530
- "store_authentication_cookie",
531
- ],
532
- }
104
+ @property
105
+ def login(self) -> AmazonLogin:
106
+ """Return login."""
107
+ return self._login
533
108
 
534
- register_url = "https://api.amazon.com/auth/register"
535
- _, raw_resp = await self._session_request(
536
- method=HTTPMethod.POST,
537
- url=register_url,
538
- input_data=body,
539
- json_data=True,
540
- )
541
- resp_json = await self._response_to_json(raw_resp)
542
-
543
- if raw_resp.status != HTTPStatus.OK:
544
- msg = resp_json["response"]["error"]["message"]
545
- _LOGGER.error(
546
- "Cannot register device for %s: %s",
547
- obfuscate_email(self._login_email),
548
- msg,
549
- )
550
- raise CannotRegisterDevice(
551
- f"{await self._http_phrase_error(raw_resp.status)}: {msg}"
552
- )
109
+ async def _get_sensors_states(self) -> dict[str, dict[str, AmazonDeviceSensor]]:
110
+ """Retrieve devices sensors states."""
111
+ devices_sensors: dict[str, dict[str, AmazonDeviceSensor]] = {}
553
112
 
554
- success_response = resp_json["response"]["success"]
555
-
556
- tokens = success_response["tokens"]
557
- adp_token = tokens["mac_dms"]["adp_token"]
558
- device_private_key = tokens["mac_dms"]["device_private_key"]
559
- store_authentication_cookie = tokens["store_authentication_cookie"]
560
- access_token = tokens["bearer"]["access_token"]
561
- refresh_token = tokens["bearer"]["refresh_token"]
562
- expires_s = int(tokens["bearer"]["expires_in"])
563
- expires = (datetime.now(UTC) + timedelta(seconds=expires_s)).timestamp()
564
-
565
- extensions = success_response["extensions"]
566
- device_info = extensions["device_info"]
567
- customer_info = extensions["customer_info"]
568
-
569
- website_cookies = {}
570
- for cookie in tokens["website_cookies"]:
571
- website_cookies[cookie["Name"]] = cookie["Value"].replace(r'"', r"")
572
-
573
- login_data = {
574
- "adp_token": adp_token,
575
- "device_private_key": device_private_key,
576
- "access_token": access_token,
577
- "refresh_token": refresh_token,
578
- "expires": expires,
579
- "website_cookies": website_cookies,
580
- "store_authentication_cookie": store_authentication_cookie,
581
- "device_info": device_info,
582
- "customer_info": customer_info,
583
- }
584
- _LOGGER.info("Register device: %s", scrub_fields(login_data))
585
- return login_data
113
+ if not self._endpoints:
114
+ return {}
586
115
 
587
- async def _get_sensors_state(
588
- self, endpoint_id_list: list[str]
589
- ) -> dict[str, Any] | list[dict[str, Any]]:
590
- """Get sensor State."""
116
+ endpoint_ids = list(self._endpoints.keys())
591
117
  payload = [
592
118
  {
593
119
  "operationName": "getEndpointState",
594
120
  "variables": {
595
- "endpointId": endpoint_id,
596
- "latencyTolerance": "LOW",
121
+ "endpointIds": endpoint_ids,
597
122
  },
598
123
  "query": QUERY_SENSOR_STATE,
599
124
  }
600
- for endpoint_id in endpoint_id_list
601
125
  ]
602
126
 
603
- _, raw_resp = await self._session_request(
127
+ _, raw_resp = await self._http_wrapper.session_request(
604
128
  method=HTTPMethod.POST,
605
- url=f"https://alexa.amazon.{self._domain}{URI_NEXUS_GRAPHQL}",
129
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NEXUS_GRAPHQL}",
606
130
  input_data=payload,
607
131
  json_data=True,
608
132
  )
609
133
 
610
- return await self._response_to_json(raw_resp)
134
+ sensors_state = await self._http_wrapper.response_to_json(raw_resp, "sensors")
611
135
 
612
- async def _get_sensors_states(self) -> dict[str, dict[str, AmazonDeviceSensor]]:
613
- """Retrieve devices sensors states."""
614
- devices_sensors: dict[str, dict[str, AmazonDeviceSensor]] = {}
615
-
616
- # batch endpoints into groups of 3 to reduce number of requests
617
- endpoint_ids = list(self._endpoints.keys())
618
- batches = [endpoint_ids[i : i + 3] for i in range(0, len(endpoint_ids), 3)]
619
- for endpoint_id_batch in batches:
620
- sensors_state = await self._get_sensors_state(endpoint_id_batch)
621
- _LOGGER.debug("Sensor data - %s", sensors_state)
136
+ if await self._format_human_error(sensors_state):
137
+ # Explicit error in returned data
138
+ return {}
622
139
 
623
- if not isinstance(sensors_state, list) and (
624
- error := sensors_state.get("errors")
625
- ):
626
- if isinstance(error, list):
627
- error = error[0]
628
- msg = error.get("message", "Unknown error")
629
- path = error.get("path", "Unknown path")
630
- _LOGGER.error(
631
- "Error retrieving devices state: %s for path %s", msg, path
632
- )
633
- return {}
140
+ if (
141
+ not (arr := sensors_state.get(ARRAY_WRAPPER))
142
+ or not (data := arr[0].get("data"))
143
+ or not (endpoints_list := data.get("listEndpoints"))
144
+ or not (endpoints := endpoints_list.get("endpoints"))
145
+ ):
146
+ _LOGGER.error("Malformed sensor state data received: %s", sensors_state)
147
+ return {}
634
148
 
635
- for endpoint_data in sensors_state:
636
- if (
637
- not isinstance(endpoint_data, dict)
638
- or not (data := endpoint_data.get("data"))
639
- or not (endpoint := data.get("endpoint"))
640
- ):
641
- _LOGGER.error(
642
- "Malformed sensor state data received: %s", endpoint_data
643
- )
644
- return {}
645
- serial_number = self._endpoints[endpoint.get("endpointId")]
149
+ for endpoint in endpoints:
150
+ serial_number = self._endpoints[endpoint.get("endpointId")]
646
151
 
647
- if serial_number in self._final_devices:
648
- devices_sensors[serial_number] = self._get_device_sensor_state(
649
- endpoint, serial_number
650
- )
152
+ if serial_number in self._final_devices:
153
+ devices_sensors[serial_number] = self._get_device_sensor_state(
154
+ endpoint, serial_number
155
+ )
651
156
 
652
157
  return devices_sensors
653
158
 
@@ -708,14 +213,16 @@ class AmazonEchoApi:
708
213
  _LOGGER.debug(
709
214
  "error in sensor %s - %s - %s", name, error_type, error_msg
710
215
  )
711
- device_sensors[name] = AmazonDeviceSensor(
712
- name,
713
- value,
714
- error,
715
- error_type,
716
- error_msg,
717
- scale,
718
- )
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
+ )
719
226
 
720
227
  return device_sensors
721
228
 
@@ -726,17 +233,17 @@ class AmazonEchoApi:
726
233
  "query": QUERY_DEVICE_DATA,
727
234
  }
728
235
 
729
- _, raw_resp = await self._session_request(
236
+ _, raw_resp = await self._http_wrapper.session_request(
730
237
  method=HTTPMethod.POST,
731
- url=f"https://alexa.amazon.{self._domain}{URI_NEXUS_GRAPHQL}",
238
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NEXUS_GRAPHQL}",
732
239
  input_data=payload,
733
240
  json_data=True,
734
241
  )
735
242
 
736
- endpoint_data = await self._response_to_json(raw_resp)
243
+ endpoint_data = await self._http_wrapper.response_to_json(raw_resp, "endpoint")
737
244
 
738
245
  if not (data := endpoint_data.get("data")) or not data.get("listEndpoints"):
739
- _LOGGER.error("Malformed endpoint data received: %s", endpoint_data)
246
+ await self._format_human_error(endpoint_data)
740
247
  return {}
741
248
 
742
249
  endpoints = data["listEndpoints"]
@@ -753,159 +260,183 @@ class AmazonEchoApi:
753
260
 
754
261
  return devices_endpoints
755
262
 
756
- async def _response_to_json(self, raw_resp: ClientResponse) -> dict[str, Any]:
757
- """Convert response to JSON, if possible."""
758
- try:
759
- data = await raw_resp.json(loads=orjson.loads)
760
- if not data:
761
- _LOGGER.warning("Empty JSON data received")
762
- data = {}
763
- return cast("dict[str, Any]", data)
764
- except ContentTypeError as exc:
765
- raise ValueError("Response not in JSON format") from exc
766
- except orjson.JSONDecodeError as exc:
767
- raise ValueError("Response with corrupted JSON format") from exc
768
-
769
- async def login_mode_interactive(self, otp_code: str) -> dict[str, Any]:
770
- """Login to Amazon interactively via OTP."""
771
- _LOGGER.debug(
772
- "Logging-in for %s [otp code: %s]",
773
- obfuscate_email(self._login_email),
774
- bool(otp_code),
775
- )
776
-
777
- device_login_data = await self._login_mode_interactive_oauth(otp_code)
263
+ async def _get_notifications(self) -> dict[str, dict[str, AmazonSchedule]]:
264
+ final_notifications: dict[str, dict[str, AmazonSchedule]] = {}
778
265
 
779
- login_data = await self._register_device(device_login_data)
780
- self._login_stored_data = login_data
266
+ _, raw_resp = await self._http_wrapper.session_request(
267
+ HTTPMethod.GET,
268
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NOTIFICATIONS}",
269
+ )
781
270
 
782
- await self._domain_refresh_auth_cookies()
271
+ notifications = await self._http_wrapper.response_to_json(
272
+ raw_resp, "notifications"
273
+ )
783
274
 
784
- self._login_stored_data.update({"site": f"https://www.amazon.{self._domain}"})
785
- await self._save_to_file(self._login_stored_data, "login_data", JSON_EXTENSION)
275
+ for schedule in notifications["notifications"]:
276
+ schedule_type: str = schedule["type"]
277
+ schedule_device_type = schedule["deviceType"]
278
+ schedule_device_serial = schedule["deviceSerialNumber"]
786
279
 
787
- # Can take a little while to register device but we need it
788
- # to be able to pickout account customer ID
789
- await asyncio.sleep(2)
280
+ if schedule_device_type in DEVICE_TO_IGNORE:
281
+ continue
790
282
 
791
- return self._login_stored_data
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
792
290
 
793
- async def _login_mode_interactive_oauth(
794
- self, otp_code: str
795
- ) -> dict[str, str | bytes]:
796
- """Login interactive via oauth URL."""
797
- code_verifier = self._create_code_verifier()
798
- client_id = self._build_client_id()
291
+ if schedule_type == NOTIFICATION_MUSIC_ALARM:
292
+ # Structure is the same as standard Alarm
293
+ schedule_type = NOTIFICATION_ALARM
294
+ schedule["type"] = NOTIFICATION_ALARM
295
+ label_desc = schedule_type.lower() + "Label"
296
+ if (schedule_status := schedule["status"]) == "ON" and (
297
+ next_occurrence := await self._parse_next_occurence(schedule)
298
+ ):
299
+ schedule_notification_list = final_notifications.get(
300
+ schedule_device_serial, {}
301
+ )
302
+ schedule_notification_by_type = schedule_notification_list.get(
303
+ schedule_type
304
+ )
305
+ # Replace if no existing notification
306
+ # or if existing.next_occurrence is None
307
+ # or if new next_occurrence is earlier
308
+ if (
309
+ not schedule_notification_by_type
310
+ or schedule_notification_by_type.next_occurrence is None
311
+ or next_occurrence < schedule_notification_by_type.next_occurrence
312
+ ):
313
+ final_notifications.update(
314
+ {
315
+ schedule_device_serial: {
316
+ **schedule_notification_list
317
+ | {
318
+ schedule_type: AmazonSchedule(
319
+ type=schedule_type,
320
+ status=schedule_status,
321
+ label=schedule[label_desc],
322
+ next_occurrence=next_occurrence,
323
+ ),
324
+ }
325
+ }
326
+ }
327
+ )
799
328
 
800
- _LOGGER.debug("Build oauth URL")
801
- login_url = self._build_oauth_url(code_verifier, client_id)
329
+ return final_notifications
802
330
 
803
- login_soup, _ = await self._session_request(
804
- method=HTTPMethod.GET, url=login_url
805
- )
806
- login_method, login_url = self._get_request_from_soup(login_soup)
807
- login_inputs = self._get_inputs_from_soup(login_soup)
808
- login_inputs["email"] = self._login_email
809
- login_inputs["password"] = self._login_password
810
-
811
- _LOGGER.debug("Register at %s", login_url)
812
- login_soup, _ = await self._session_request(
813
- method=login_method,
814
- url=login_url,
815
- input_data=login_inputs,
816
- )
331
+ async def _parse_next_occurence(
332
+ self,
333
+ schedule: dict[str, Any],
334
+ ) -> datetime | None:
335
+ """Parse RFC5545 rule set for next iteration."""
336
+ # Local timezone
337
+ tzinfo = datetime.now().astimezone().tzinfo
338
+ # Current time
339
+ actual_time = datetime.now(tz=tzinfo)
340
+ # Reference start date
341
+ today_midnight = actual_time.replace(hour=0, minute=0, second=0, microsecond=0)
342
+ # Reference time (1 minute ago to avoid edge cases)
343
+ now_reference = actual_time - timedelta(minutes=1)
344
+
345
+ # Schedule data
346
+ original_date = schedule.get("originalDate")
347
+ original_time = schedule.get("originalTime")
348
+
349
+ recurring_rules: list[str] = []
350
+ if schedule.get("rRuleData"):
351
+ recurring_rules = schedule["rRuleData"]["recurrenceRules"]
352
+ if schedule.get("recurringPattern"):
353
+ recurring_rules.append(schedule["recurringPattern"])
354
+
355
+ # Recurring events
356
+ if recurring_rules:
357
+ next_candidates: list[datetime] = []
358
+ for recurring_rule in recurring_rules:
359
+ # Already in RFC5545 format
360
+ if "FREQ=" in recurring_rule:
361
+ rule = await self._add_hours_minutes(recurring_rule, original_time)
362
+
363
+ # Add date to candidates list
364
+ next_candidates.append(
365
+ rrulestr(rule, dtstart=today_midnight).after(
366
+ now_reference, True
367
+ ),
368
+ )
369
+ continue
817
370
 
818
- if not login_soup.find("input", id="auth-mfa-otpcode"):
819
- _LOGGER.debug(
820
- 'Cannot find "auth-mfa-otpcode" in html source [%s]', login_url
821
- )
822
- raise CannotAuthenticate("MFA OTP code not found on login page")
371
+ if recurring_rule not in RECURRING_PATTERNS:
372
+ _LOGGER.warning(
373
+ "Unknown recurring rule <%s> for schedule type <%s>",
374
+ recurring_rule,
375
+ schedule["type"],
376
+ )
377
+ return None
823
378
 
824
- login_method, login_url = self._get_request_from_soup(login_soup)
379
+ # Adjust recurring rules for country specific weekend exceptions
380
+ recurring_pattern = RECURRING_PATTERNS.copy()
381
+ for group, countries in COUNTRY_GROUPS.items():
382
+ if self._session_state_data.country_code in countries:
383
+ recurring_pattern |= WEEKEND_EXCEPTIONS[group]
384
+ break
825
385
 
826
- login_inputs = self._get_inputs_from_soup(login_soup)
827
- login_inputs["otpCode"] = otp_code
828
- login_inputs["mfaSubmit"] = "Submit"
829
- login_inputs["rememberDevice"] = "false"
386
+ rule = await self._add_hours_minutes(
387
+ recurring_pattern[recurring_rule], original_time
388
+ )
830
389
 
831
- login_soup, login_resp = await self._session_request(
832
- method=login_method,
833
- url=login_url,
834
- input_data=login_inputs,
835
- )
836
- _LOGGER.debug("Login response url:%s", login_resp.url)
390
+ # Add date to candidates list
391
+ next_candidates.append(
392
+ rrulestr(rule, dtstart=today_midnight).after(now_reference, True),
393
+ )
837
394
 
838
- authcode = self._extract_code_from_url(login_resp.url)
839
- _LOGGER.debug("Login extracted authcode: %s", authcode)
395
+ return min(next_candidates) if next_candidates else None
840
396
 
841
- return {
842
- "authorization_code": authcode,
843
- "code_verifier": code_verifier,
844
- "domain": self._domain,
845
- }
397
+ # Single events
398
+ if schedule["type"] == NOTIFICATION_ALARM:
399
+ timestamp = parse(f"{original_date} {original_time}").replace(tzinfo=tzinfo)
846
400
 
847
- async def login_mode_stored_data(self) -> dict[str, Any]:
848
- """Login to Amazon using previously stored data."""
849
- if not self._login_stored_data:
850
- _LOGGER.debug(
851
- "Cannot find previous login data,\
852
- use login_mode_interactive() method instead",
401
+ elif schedule["type"] == NOTIFICATION_TIMER:
402
+ # API returns triggerTime in milliseconds since epoch
403
+ timestamp = datetime.fromtimestamp(
404
+ schedule["triggerTime"] / 1000, tz=tzinfo
853
405
  )
854
- raise WrongMethod
855
406
 
856
- _LOGGER.debug(
857
- "Logging-in for %s with stored data",
858
- obfuscate_email(self._login_email),
859
- )
407
+ elif schedule["type"] == NOTIFICATION_REMINDER:
408
+ # API returns alarmTime in milliseconds since epoch
409
+ timestamp = datetime.fromtimestamp(schedule["alarmTime"] / 1000, tz=tzinfo)
860
410
 
861
- # Check if session is still authenticated
862
- if not await self.auth_check_status():
863
- raise CannotAuthenticate("Session no longer authenticated")
411
+ else:
412
+ _LOGGER.warning(("Unknown schedule type: %s"), schedule["type"])
413
+ return None
864
414
 
865
- return self._login_stored_data
415
+ if timestamp > now_reference:
416
+ return timestamp
866
417
 
867
- async def _get_alexa_domain(self) -> str:
868
- """Get the Alexa domain."""
869
- _LOGGER.debug("Retrieve Alexa domain")
870
- _, raw_resp = await self._session_request(
871
- method=HTTPMethod.GET,
872
- url=f"https://alexa.amazon.{self._domain}/api/welcome",
873
- )
874
- json_data = await self._response_to_json(raw_resp)
875
- return cast(
876
- "str", json_data.get("alexaHostName", f"alexa.amazon.{self._domain}")
877
- )
418
+ return None
878
419
 
879
- async def _refresh_auth_cookies(self) -> None:
880
- """Refresh cookies after domain swap."""
881
- _, json_token_resp = await self._refresh_data(REFRESH_AUTH_COOKIES)
882
-
883
- # Need to take cookies from response and create them as cookies
884
- website_cookies = self._login_stored_data["website_cookies"] = {}
885
- self._session.cookie_jar.clear()
886
-
887
- cookie_json = json_token_resp["response"]["tokens"]["cookies"]
888
- for cookie_domain in cookie_json:
889
- for cookie in cookie_json[cookie_domain]:
890
- new_cookie_value = cookie["Value"].replace(r'"', r"")
891
- new_cookie = {cookie["Name"]: new_cookie_value}
892
- self._session.cookie_jar.update_cookies(new_cookie, URL(cookie_domain))
893
- website_cookies.update(new_cookie)
894
- if cookie["Name"] == "session-token":
895
- self._login_stored_data["store_authentication_cookie"] = {
896
- "cookie": new_cookie_value
897
- }
420
+ async def _add_hours_minutes(
421
+ self,
422
+ recurring_rule: str,
423
+ original_time: str | None,
424
+ ) -> str:
425
+ """Add hours and minutes to a RFC5545 string."""
426
+ rule = recurring_rule.removesuffix(";")
898
427
 
899
- async def _domain_refresh_auth_cookies(self) -> None:
900
- """Refresh cookies after domain swap."""
901
- _LOGGER.debug("Refreshing auth cookies after domain change")
428
+ if not original_time:
429
+ return rule
902
430
 
903
- # Get the new Alexa domain
904
- user_domain = (await self._get_alexa_domain()).replace("alexa", "https://www")
905
- if user_domain != DEFAULT_SITE:
906
- _LOGGER.debug("User domain changed to %s", user_domain)
907
- self._country_specific_data(user_domain)
908
- await self._refresh_auth_cookies()
431
+ # Add missing BYHOUR, BYMINUTE if needed (Alarms only)
432
+ if "BYHOUR=" not in recurring_rule:
433
+ hour = int(original_time.split(":")[0])
434
+ rule += f";BYHOUR={hour}"
435
+ if "BYMINUTE=" not in recurring_rule:
436
+ minute = int(original_time.split(":")[1])
437
+ rule += f";BYMINUTE={minute}"
438
+
439
+ return rule
909
440
 
910
441
  async def _get_account_owner_customer_id(self, data: dict[str, Any]) -> str | None:
911
442
  """Get account owner customer ID."""
@@ -914,7 +445,7 @@ class AmazonEchoApi:
914
445
 
915
446
  account_owner_customer_id: str | None = None
916
447
 
917
- this_device_serial = self._login_stored_data["device_info"][
448
+ this_device_serial = self._session_state_data.login_stored_data["device_info"][
918
449
  "device_serial_number"
919
450
  ]
920
451
 
@@ -943,8 +474,13 @@ class AmazonEchoApi:
943
474
  await self._get_base_devices()
944
475
  self._last_devices_refresh = datetime.now(UTC)
945
476
 
477
+ # Only refresh endpoint data if we have no endpoints yet
946
478
  delta_endpoints = datetime.now(UTC) - self._last_endpoint_refresh
947
- if delta_endpoints >= timedelta(minutes=30):
479
+ endpoint_refresh_needed = delta_endpoints >= timedelta(days=1)
480
+ endpoints_recently_checked = delta_endpoints < timedelta(minutes=30)
481
+ if (
482
+ not self._endpoints and not endpoints_recently_checked
483
+ ) or endpoint_refresh_needed:
948
484
  _LOGGER.debug(
949
485
  "Refreshing endpoint data after %s",
950
486
  str(timedelta(minutes=round(delta_endpoints.total_seconds() / 60))),
@@ -960,6 +496,7 @@ class AmazonEchoApi:
960
496
  async def _get_sensor_data(self) -> None:
961
497
  devices_sensors = await self._get_sensors_states()
962
498
  dnd_sensors = await self._get_dnd_status()
499
+ notifications = await self._get_notifications()
963
500
  for device in self._final_devices.values():
964
501
  # Update sensors
965
502
  sensors = devices_sensors.get(device.serial_number, {})
@@ -968,10 +505,36 @@ class AmazonEchoApi:
968
505
  else:
969
506
  for device_sensor in device.sensors.values():
970
507
  device_sensor.error = True
971
- 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:
972
511
  device.sensors["dnd"] = device_dnd
973
512
 
513
+ # Clear old notifications to handle cancelled ones
514
+ device.notifications = {}
515
+
516
+ # Update notifications
517
+ device_notifications = notifications.get(device.serial_number, {})
518
+
519
+ # Add only supported notification types
520
+ for capability, notification_type in [
521
+ ("REMINDERS", NOTIFICATION_REMINDER),
522
+ ("TIMERS_AND_ALARMS", NOTIFICATION_ALARM),
523
+ ("TIMERS_AND_ALARMS", NOTIFICATION_TIMER),
524
+ ]:
525
+ if (
526
+ capability in device.capabilities
527
+ and notification_type in device_notifications
528
+ and (
529
+ notification_object := device_notifications.get(
530
+ notification_type
531
+ )
532
+ )
533
+ ):
534
+ device.notifications[notification_type] = notification_object
535
+
974
536
  async def _set_device_endpoints_data(self) -> None:
537
+ """Set device endpoint data."""
975
538
  devices_endpoints = await self._get_devices_endpoint_data()
976
539
  for serial_number in self._final_devices:
977
540
  device_endpoint = devices_endpoints.get(serial_number, {})
@@ -986,14 +549,12 @@ class AmazonEchoApi:
986
549
  )
987
550
 
988
551
  async def _get_base_devices(self) -> None:
989
- _, raw_resp = await self._session_request(
552
+ _, raw_resp = await self._http_wrapper.session_request(
990
553
  method=HTTPMethod.GET,
991
- url=f"https://alexa.amazon.{self._domain}{URI_DEVICES}",
554
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DEVICES}",
992
555
  )
993
556
 
994
- json_data = await self._response_to_json(raw_resp)
995
-
996
- _LOGGER.debug("JSON devices data: %s", scrub_fields(json_data))
557
+ json_data = await self._http_wrapper.response_to_json(raw_resp, "devices")
997
558
 
998
559
  for data in json_data["devices"]:
999
560
  dev_serial = data.get("serialNumber")
@@ -1016,11 +577,20 @@ class AmazonEchoApi:
1016
577
  if not device or (device.get("deviceType") in DEVICE_TO_IGNORE):
1017
578
  continue
1018
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
+
1019
589
  serial_number: str = device["serialNumber"]
1020
590
 
1021
591
  final_devices_list[serial_number] = AmazonDevice(
1022
- account_name=device["accountName"],
1023
- capabilities=device["capabilities"],
592
+ account_name=account_name,
593
+ capabilities=capabilities,
1024
594
  device_family=device["deviceFamily"],
1025
595
  device_type=device["deviceType"],
1026
596
  device_owner_customer_id=device["deviceOwnerCustomerId"],
@@ -1033,6 +603,7 @@ class AmazonEchoApi:
1033
603
  entity_id=None,
1034
604
  endpoint_id=None,
1035
605
  sensors={},
606
+ notifications={},
1036
607
  )
1037
608
 
1038
609
  self._list_for_clusters.update(
@@ -1044,29 +615,6 @@ class AmazonEchoApi:
1044
615
 
1045
616
  self._final_devices = final_devices_list
1046
617
 
1047
- async def auth_check_status(self) -> bool:
1048
- """Check AUTH status."""
1049
- _, raw_resp = await self._session_request(
1050
- method=HTTPMethod.GET,
1051
- url=f"https://alexa.amazon.{self._domain}/api/bootstrap?version=0",
1052
- agent="Browser",
1053
- )
1054
- if raw_resp.status != HTTPStatus.OK:
1055
- _LOGGER.debug(
1056
- "Session not authenticated: reply error %s",
1057
- raw_resp.status,
1058
- )
1059
- return False
1060
-
1061
- resp_json = await self._response_to_json(raw_resp)
1062
- if not (authentication := resp_json.get("authentication")):
1063
- _LOGGER.debug('Session not authenticated: reply missing "authentication"')
1064
- return False
1065
-
1066
- authenticated = authentication.get("authenticated")
1067
- _LOGGER.debug("Session authenticated: %s", authenticated)
1068
- return bool(authenticated)
1069
-
1070
618
  def get_model_details(self, device: AmazonDevice) -> dict[str, str | None] | None:
1071
619
  """Return model datails."""
1072
620
  model_details: dict[str, str | None] | None = DEVICE_TYPE_TO_MODEL.get(
@@ -1089,14 +637,14 @@ class AmazonEchoApi:
1089
637
  message_source: AmazonMusicSource | None = None,
1090
638
  ) -> None:
1091
639
  """Send message to specific device."""
1092
- if not self._login_stored_data:
640
+ if not self._session_state_data.login_stored_data:
1093
641
  _LOGGER.warning("No login data available, cannot send message")
1094
642
  return
1095
643
 
1096
644
  base_payload = {
1097
645
  "deviceType": device.device_type,
1098
646
  "deviceSerialNumber": device.serial_number,
1099
- "locale": self._language,
647
+ "locale": self._session_state_data.language,
1100
648
  "customerId": self._account_owner_customer_id,
1101
649
  }
1102
650
 
@@ -1131,7 +679,7 @@ class AmazonEchoApi:
1131
679
  "expireAfter": "PT5S",
1132
680
  "content": [
1133
681
  {
1134
- "locale": self._language,
682
+ "locale": self._session_state_data.language,
1135
683
  "display": {
1136
684
  "title": "Home Assistant",
1137
685
  "body": message_body,
@@ -1206,9 +754,9 @@ class AmazonEchoApi:
1206
754
  }
1207
755
 
1208
756
  _LOGGER.debug("Preview data payload: %s", node_data)
1209
- await self._session_request(
757
+ await self._http_wrapper.session_request(
1210
758
  method=HTTPMethod.POST,
1211
- url=f"https://alexa.amazon.{self._domain}/api/behaviors/preview",
759
+ url=f"https://alexa.amazon.{self._session_state_data.domain}/api/behaviors/preview",
1212
760
  input_data=node_data,
1213
761
  json_data=True,
1214
762
  )
@@ -1287,77 +835,22 @@ class AmazonEchoApi:
1287
835
  "deviceType": device.device_type,
1288
836
  "enabled": state,
1289
837
  }
1290
- url = f"https://alexa.amazon.{self._domain}/api/dnd/status"
1291
- await self._session_request(
1292
- method="PUT", url=url, input_data=payload, json_data=True
1293
- )
1294
-
1295
- async def _refresh_data(self, data_type: str) -> tuple[bool, dict]:
1296
- """Refresh data."""
1297
- if not self._login_stored_data:
1298
- _LOGGER.debug("No login data available, cannot refresh")
1299
- return False, {}
1300
-
1301
- data = {
1302
- "app_name": AMAZON_APP_NAME,
1303
- "app_version": AMAZON_APP_VERSION,
1304
- "di.sdk.version": "6.12.4",
1305
- "source_token": self._login_stored_data["refresh_token"],
1306
- "package_name": AMAZON_APP_BUNDLE_ID,
1307
- "di.hw.version": "iPhone",
1308
- "platform": "iOS",
1309
- "requested_token_type": data_type,
1310
- "source_token_type": "refresh_token",
1311
- "di.os.name": "iOS",
1312
- "di.os.version": AMAZON_CLIENT_OS,
1313
- "current_version": "6.12.4",
1314
- "previous_version": "6.12.4",
1315
- "domain": f"www.amazon.{self._domain}",
1316
- }
1317
-
1318
- _, raw_resp = await self._session_request(
1319
- HTTPMethod.POST,
1320
- "https://api.amazon.com/auth/token",
1321
- input_data=data,
1322
- json_data=False,
1323
- )
1324
- _LOGGER.debug(
1325
- "Refresh data response %s with payload %s",
1326
- raw_resp.status,
1327
- 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,
1328
844
  )
1329
845
 
1330
- if raw_resp.status != HTTPStatus.OK:
1331
- _LOGGER.debug("Failed to refresh data")
1332
- return False, {}
1333
-
1334
- json_response = await self._response_to_json(raw_resp)
1335
- _LOGGER.debug("Refresh data json:\n%s ", json_response)
1336
-
1337
- if data_type == REFRESH_ACCESS_TOKEN and (
1338
- new_token := json_response.get(REFRESH_ACCESS_TOKEN)
1339
- ):
1340
- self._login_stored_data[REFRESH_ACCESS_TOKEN] = new_token
1341
- self.expires_in = datetime.now(tz=UTC).timestamp() + int(
1342
- json_response.get("expires_in", 0)
1343
- )
1344
- return True, json_response
1345
-
1346
- if data_type == REFRESH_AUTH_COOKIES:
1347
- return True, json_response
1348
-
1349
- _LOGGER.debug("Unexpected refresh data response")
1350
- return False, {}
1351
-
1352
846
  async def _get_dnd_status(self) -> dict[str, AmazonDeviceSensor]:
1353
847
  dnd_status: dict[str, AmazonDeviceSensor] = {}
1354
- _, raw_resp = await self._session_request(
848
+ _, raw_resp = await self._http_wrapper.session_request(
1355
849
  method=HTTPMethod.GET,
1356
- url=f"https://alexa.amazon.{self._domain}{URI_DND}",
850
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DND}",
1357
851
  )
1358
852
 
1359
- dnd_data = await self._response_to_json(raw_resp)
1360
- _LOGGER.debug("DND data: %s", dnd_data)
853
+ dnd_data = await self._http_wrapper.response_to_json(raw_resp, "dnd")
1361
854
 
1362
855
  for dnd in dnd_data.get("doNotDisturbDeviceStatusList", {}):
1363
856
  dnd_status[dnd.get("deviceSerialNumber")] = AmazonDeviceSensor(
@@ -1369,3 +862,18 @@ class AmazonEchoApi:
1369
862
  scale=None,
1370
863
  )
1371
864
  return dnd_status
865
+
866
+ async def _format_human_error(self, sensors_state: dict) -> bool:
867
+ """Format human readable error from malformed data."""
868
+ if sensors_state.get(ARRAY_WRAPPER):
869
+ error = sensors_state[ARRAY_WRAPPER][0].get("errors", [])
870
+ else:
871
+ error = sensors_state.get("errors", [])
872
+
873
+ if not error:
874
+ return False
875
+
876
+ msg = error[0].get("message", "Unknown error")
877
+ path = error[0].get("path", "Unknown path")
878
+ _LOGGER.error("Error retrieving devices state: %s for path %s", msg, path)
879
+ return True