aioamazondevices 6.5.1__py3-none-any.whl → 11.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,47 @@
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
17
-
18
- import orjson
19
- from aiohttp import (
20
- ClientConnectorError,
21
- ClientResponse,
22
- ClientSession,
23
- ContentTypeError,
24
- )
25
- from bs4 import BeautifulSoup, Tag
26
- from dateutil.parser import parse
27
- from dateutil.rrule import rrulestr
28
- from langcodes import Language, standardize_tag
29
- from multidict import MultiDictProxy
30
- from yarl import URL
5
+ from http import HTTPMethod
6
+ from typing import Any
7
+
8
+ from aiohttp import ClientSession
31
9
 
32
10
  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,
42
- AMAZON_DEVICE_TYPE,
43
- ARRAY_WRAPPER,
44
- BIN_EXTENSION,
45
- COUNTRY_GROUPS,
46
- CSRF_COOKIE,
47
- DEFAULT_HEADERS,
48
- DEFAULT_SITE,
11
+ from .const.devices import (
49
12
  DEVICE_TO_IGNORE,
50
13
  DEVICE_TYPE_TO_MODEL,
51
- HTML_EXTENSION,
52
- HTTP_ERROR_199,
53
- HTTP_ERROR_299,
54
- JSON_EXTENSION,
55
- NOTIFICATION_ALARM,
56
- NOTIFICATION_MUSIC_ALARM,
57
- NOTIFICATION_REMINDER,
58
- NOTIFICATION_TIMER,
59
- RECURRING_PATTERNS,
60
- REFRESH_ACCESS_TOKEN,
61
- REFRESH_AUTH_COOKIES,
14
+ SPEAKER_GROUP_FAMILY,
15
+ )
16
+ from .const.http import (
17
+ ARRAY_WRAPPER,
18
+ DEFAULT_SITE,
62
19
  REQUEST_AGENT,
63
- SAVE_PATH,
64
- SENSORS,
65
20
  URI_DEVICES,
66
- URI_DND,
67
21
  URI_NEXUS_GRAPHQL,
68
- URI_NOTIFICATIONS,
69
- URI_SIGNIN,
70
- WEEKEND_EXCEPTIONS,
22
+ )
23
+ from .const.metadata import SENSORS
24
+ from .const.queries import QUERY_DEVICE_DATA, QUERY_SENSOR_STATE
25
+ from .const.schedules import (
26
+ NOTIFICATION_ALARM,
27
+ NOTIFICATION_REMINDER,
28
+ NOTIFICATION_TIMER,
71
29
  )
72
30
  from .exceptions import (
73
- CannotAuthenticate,
74
- CannotConnect,
75
- CannotRegisterDevice,
76
31
  CannotRetrieveData,
77
- WrongMethod,
78
32
  )
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"
33
+ from .http_wrapper import AmazonHttpWrapper, AmazonSessionStateData
34
+ from .implementation.dnd import AmazonDnDHandler
35
+ from .implementation.notification import AmazonNotificationHandler
36
+ from .implementation.sequence import AmazonSequenceHandler
37
+ from .login import AmazonLogin
38
+ from .structures import (
39
+ AmazonDevice,
40
+ AmazonDeviceSensor,
41
+ AmazonMusicSource,
42
+ AmazonSequenceType,
43
+ )
44
+ from .utils import _LOGGER
141
45
 
142
46
 
143
47
  class AmazonEchoApi:
@@ -149,468 +53,69 @@ class AmazonEchoApi:
149
53
  login_email: str,
150
54
  login_password: str,
151
55
  login_data: dict[str, Any] | None = None,
56
+ save_to_file: Callable[[str | dict, str, str], Coroutine[Any, Any, None]]
57
+ | None = None,
152
58
  ) -> None:
153
59
  """Initialize the scanner."""
60
+ _LOGGER.debug("Initialize library v%s", __version__)
61
+
154
62
  # Check if there is a previous login, otherwise use default (US)
155
63
  site = login_data.get("site", DEFAULT_SITE) if login_data else DEFAULT_SITE
156
64
  _LOGGER.debug("Using site: %s", site)
157
- self._country_specific_data(site)
158
-
159
- self._login_email = login_email
160
- self._login_password = login_password
161
65
 
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
- self._account_owner_customer_id: str | None = None
167
- self._list_for_clusters: dict[str, str] = {}
168
-
169
- self._session = client_session
170
- self._final_devices: dict[str, AmazonDevice] = {}
171
- self._endpoints: dict[str, str] = {} # endpoint ID to serial number map
172
-
173
- initial_time = datetime.now(UTC) - timedelta(days=2) # force initial refresh
174
- self._last_devices_refresh: datetime = initial_time
175
- self._last_endpoint_refresh: datetime = initial_time
176
-
177
- _LOGGER.debug("Initialize library v%s", __version__)
178
-
179
- @property
180
- def domain(self) -> str:
181
- """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,
66
+ self._session_state_data = AmazonSessionStateData(
67
+ site, login_email, login_password, login_data
211
68
  )
212
69
 
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
- }
70
+ self._http_wrapper = AmazonHttpWrapper(
71
+ client_session,
72
+ self._session_state_data,
73
+ save_to_file,
225
74
  )
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
75
 
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,
76
+ self._login = AmazonLogin(
77
+ http_wrapper=self._http_wrapper,
78
+ session_state_data=self._session_state_data,
383
79
  )
384
80
 
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
81
+ self._notification_handler = AmazonNotificationHandler(
82
+ http_wrapper=self._http_wrapper,
83
+ session_state_data=self._session_state_data,
401
84
  )
402
- self._session.cookie_jar.update_cookies(_cookies, URL(f"amazon.{self._domain}"))
403
85
 
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,
86
+ self._sequence_handler = AmazonSequenceHandler(
87
+ http_wrapper=self._http_wrapper,
88
+ session_state_data=self._session_state_data,
450
89
  )
451
90
 
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",
91
+ self._dnd_handler = AmazonDnDHandler(
92
+ http_wrapper=self._http_wrapper, session_state_data=self._session_state_data
468
93
  )
469
94
 
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)
485
-
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")
95
+ self._final_devices: dict[str, AmazonDevice] = {}
96
+ self._endpoints: dict[str, str] = {} # endpoint ID to serial number map
515
97
 
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
- }
98
+ initial_time = datetime.now(UTC) - timedelta(days=2) # force initial refresh
99
+ self._last_devices_refresh: datetime = initial_time
100
+ self._last_endpoint_refresh: datetime = initial_time
556
101
 
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
- )
102
+ @property
103
+ def domain(self) -> str:
104
+ """Return current Amazon domain."""
105
+ return self._session_state_data.domain
576
106
 
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
107
+ @property
108
+ def login(self) -> AmazonLogin:
109
+ """Return login."""
110
+ return self._login
609
111
 
610
112
  async def _get_sensors_states(self) -> dict[str, dict[str, AmazonDeviceSensor]]:
611
113
  """Retrieve devices sensors states."""
612
114
  devices_sensors: dict[str, dict[str, AmazonDeviceSensor]] = {}
613
115
 
116
+ if not self._endpoints:
117
+ return {}
118
+
614
119
  endpoint_ids = list(self._endpoints.keys())
615
120
  payload = [
616
121
  {
@@ -622,15 +127,15 @@ class AmazonEchoApi:
622
127
  }
623
128
  ]
624
129
 
625
- _, raw_resp = await self._session_request(
130
+ _, raw_resp = await self._http_wrapper.session_request(
626
131
  method=HTTPMethod.POST,
627
- url=f"https://alexa.amazon.{self._domain}{URI_NEXUS_GRAPHQL}",
132
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NEXUS_GRAPHQL}",
628
133
  input_data=payload,
629
134
  json_data=True,
135
+ extended_headers={"User-Agent": REQUEST_AGENT["Amazon"]},
630
136
  )
631
137
 
632
- sensors_state = await self._response_to_json(raw_resp)
633
- _LOGGER.debug("Sensor data - %s", sensors_state)
138
+ sensors_state = await self._http_wrapper.response_to_json(raw_resp, "sensors")
634
139
 
635
140
  if await self._format_human_error(sensors_state):
636
141
  # Explicit error in returned data
@@ -712,14 +217,16 @@ class AmazonEchoApi:
712
217
  _LOGGER.debug(
713
218
  "error in sensor %s - %s - %s", name, error_type, error_msg
714
219
  )
715
- device_sensors[name] = AmazonDeviceSensor(
716
- name,
717
- value,
718
- error,
719
- error_type,
720
- error_msg,
721
- scale,
722
- )
220
+
221
+ if error_type != "NOT_FOUND":
222
+ device_sensors[name] = AmazonDeviceSensor(
223
+ name,
224
+ value,
225
+ error,
226
+ error_type,
227
+ error_msg,
228
+ scale,
229
+ )
723
230
 
724
231
  return device_sensors
725
232
 
@@ -730,14 +237,15 @@ class AmazonEchoApi:
730
237
  "query": QUERY_DEVICE_DATA,
731
238
  }
732
239
 
733
- _, raw_resp = await self._session_request(
240
+ _, raw_resp = await self._http_wrapper.session_request(
734
241
  method=HTTPMethod.POST,
735
- url=f"https://alexa.amazon.{self._domain}{URI_NEXUS_GRAPHQL}",
242
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_NEXUS_GRAPHQL}",
736
243
  input_data=payload,
737
244
  json_data=True,
245
+ extended_headers={"User-Agent": REQUEST_AGENT["Amazon"]},
738
246
  )
739
247
 
740
- endpoint_data = await self._response_to_json(raw_resp)
248
+ endpoint_data = await self._http_wrapper.response_to_json(raw_resp, "endpoint")
741
249
 
742
250
  if not (data := endpoint_data.get("data")) or not data.get("listEndpoints"):
743
251
  await self._format_human_error(endpoint_data)
@@ -757,343 +265,6 @@ class AmazonEchoApi:
757
265
 
758
266
  return devices_endpoints
759
267
 
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
- async def _get_notifications(self) -> dict[str, dict[str, AmazonSchedule]]:
778
- final_notifications: dict[str, dict[str, AmazonSchedule]] = {}
779
-
780
- _, raw_resp = await self._session_request(
781
- HTTPMethod.GET,
782
- url=f"https://alexa.amazon.{self._domain}{URI_NOTIFICATIONS}",
783
- )
784
- notifications = await self._response_to_json(raw_resp)
785
- for schedule in notifications["notifications"]:
786
- schedule_type: str = schedule["type"]
787
- schedule_device_serial = schedule["deviceSerialNumber"]
788
- if schedule_type == NOTIFICATION_MUSIC_ALARM:
789
- # Structure is the same as standard Alarm
790
- schedule_type = NOTIFICATION_ALARM
791
- schedule["type"] = NOTIFICATION_ALARM
792
- label_desc = schedule_type.lower() + "Label"
793
- if (schedule_status := schedule["status"]) == "ON" and (
794
- next_occurrence := await self._parse_next_occurence(schedule)
795
- ):
796
- schedule_notification_list = final_notifications.get(
797
- schedule_device_serial, {}
798
- )
799
- schedule_notification_by_type = schedule_notification_list.get(
800
- schedule_type
801
- )
802
- # Replace if no existing notification
803
- # or if existing.next_occurrence is None
804
- # or if new next_occurrence is earlier
805
- if (
806
- not schedule_notification_by_type
807
- or schedule_notification_by_type.next_occurrence is None
808
- or next_occurrence < schedule_notification_by_type.next_occurrence
809
- ):
810
- final_notifications.update(
811
- {
812
- schedule_device_serial: {
813
- **schedule_notification_list
814
- | {
815
- schedule_type: AmazonSchedule(
816
- type=schedule_type,
817
- status=schedule_status,
818
- label=schedule[label_desc],
819
- next_occurrence=next_occurrence,
820
- ),
821
- }
822
- }
823
- }
824
- )
825
-
826
- return final_notifications
827
-
828
- async def _parse_next_occurence(
829
- self,
830
- schedule: dict[str, Any],
831
- ) -> datetime | None:
832
- """Parse RFC5545 rule set for next iteration."""
833
- # Local timezone
834
- tzinfo = datetime.now().astimezone().tzinfo
835
- # Current time
836
- actual_time = datetime.now(tz=tzinfo)
837
- # Reference start date
838
- today_midnight = actual_time.replace(hour=0, minute=0, second=0, microsecond=0)
839
- # Reference time (1 minute ago to avoid edge cases)
840
- now_reference = actual_time - timedelta(minutes=1)
841
-
842
- # Schedule data
843
- original_date = schedule.get("originalDate")
844
- original_time = schedule.get("originalTime")
845
-
846
- recurring_rules: list[str] = []
847
- if schedule.get("rRuleData"):
848
- recurring_rules = schedule["rRuleData"]["recurrenceRules"]
849
- if schedule.get("recurringPattern"):
850
- recurring_rules.append(schedule["recurringPattern"])
851
-
852
- # Recurring events
853
- if recurring_rules:
854
- next_candidates: list[datetime] = []
855
- for recurring_rule in recurring_rules:
856
- # Already in RFC5545 format
857
- if "FREQ=" in recurring_rule:
858
- rule = await self._add_hours_minutes(recurring_rule, original_time)
859
-
860
- # Add date to candidates list
861
- next_candidates.append(
862
- rrulestr(rule, dtstart=today_midnight).after(
863
- now_reference, True
864
- ),
865
- )
866
- continue
867
-
868
- if recurring_rule not in RECURRING_PATTERNS:
869
- _LOGGER.warning("Unknown recurring rule: %s", recurring_rule)
870
- return None
871
-
872
- # Adjust recurring rules for country specific weekend exceptions
873
- recurring_pattern = RECURRING_PATTERNS.copy()
874
- for group, countries in COUNTRY_GROUPS.items():
875
- if self._country_code in countries:
876
- recurring_pattern |= WEEKEND_EXCEPTIONS[group]
877
- break
878
-
879
- rule = await self._add_hours_minutes(
880
- recurring_pattern[recurring_rule], original_time
881
- )
882
-
883
- # Add date to candidates list
884
- next_candidates.append(
885
- rrulestr(rule, dtstart=today_midnight).after(now_reference, True),
886
- )
887
-
888
- return min(next_candidates) if next_candidates else None
889
-
890
- # Single events
891
- if schedule["type"] == NOTIFICATION_ALARM:
892
- timestamp = parse(f"{original_date} {original_time}").replace(tzinfo=tzinfo)
893
-
894
- elif schedule["type"] == NOTIFICATION_TIMER:
895
- # API returns triggerTime in milliseconds since epoch
896
- timestamp = datetime.fromtimestamp(
897
- schedule["triggerTime"] / 1000, tz=tzinfo
898
- )
899
-
900
- elif schedule["type"] == NOTIFICATION_REMINDER:
901
- # API returns alarmTime in milliseconds since epoch
902
- timestamp = datetime.fromtimestamp(schedule["alarmTime"] / 1000, tz=tzinfo)
903
-
904
- else:
905
- _LOGGER.warning(("Unknown schedule type: %s"), schedule["type"])
906
- return None
907
-
908
- if timestamp > now_reference:
909
- return timestamp
910
-
911
- return None
912
-
913
- async def _add_hours_minutes(
914
- self,
915
- recurring_rule: str,
916
- original_time: str | None,
917
- ) -> str:
918
- """Add hours and minutes to a RFC5545 string."""
919
- rule = recurring_rule.removesuffix(";")
920
-
921
- if not original_time:
922
- return rule
923
-
924
- # Add missing BYHOUR, BYMINUTE if needed (Alarms only)
925
- if "BYHOUR=" not in recurring_rule:
926
- hour = int(original_time.split(":")[0])
927
- rule += f";BYHOUR={hour}"
928
- if "BYMINUTE=" not in recurring_rule:
929
- minute = int(original_time.split(":")[1])
930
- rule += f";BYMINUTE={minute}"
931
-
932
- return rule
933
-
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
- async def _get_account_owner_customer_id(self, data: dict[str, Any]) -> str | None:
1076
- """Get account owner customer ID."""
1077
- if data["deviceType"] != AMAZON_DEVICE_TYPE:
1078
- return None
1079
-
1080
- account_owner_customer_id: str | None = None
1081
-
1082
- this_device_serial = self._login_stored_data["device_info"][
1083
- "device_serial_number"
1084
- ]
1085
-
1086
- for subdevice in data["appDeviceList"]:
1087
- if subdevice["serialNumber"] == this_device_serial:
1088
- account_owner_customer_id = data["deviceOwnerCustomerId"]
1089
- _LOGGER.debug(
1090
- "Setting account owner: %s",
1091
- account_owner_customer_id,
1092
- )
1093
- break
1094
-
1095
- return account_owner_customer_id
1096
-
1097
268
  async def get_devices_data(
1098
269
  self,
1099
270
  ) -> dict[str, AmazonDevice]:
@@ -1129,8 +300,8 @@ class AmazonEchoApi:
1129
300
 
1130
301
  async def _get_sensor_data(self) -> None:
1131
302
  devices_sensors = await self._get_sensors_states()
1132
- dnd_sensors = await self._get_dnd_status()
1133
- notifications = await self._get_notifications()
303
+ dnd_sensors = await self._dnd_handler.get_do_not_disturb_status()
304
+ notifications = await self._notification_handler.get_notifications()
1134
305
  for device in self._final_devices.values():
1135
306
  # Update sensors
1136
307
  sensors = devices_sensors.get(device.serial_number, {})
@@ -1139,9 +310,17 @@ class AmazonEchoApi:
1139
310
  else:
1140
311
  for device_sensor in device.sensors.values():
1141
312
  device_sensor.error = True
1142
- if device_dnd := dnd_sensors.get(device.serial_number):
313
+ if (
314
+ device_dnd := dnd_sensors.get(device.serial_number)
315
+ ) and device.device_family != SPEAKER_GROUP_FAMILY:
1143
316
  device.sensors["dnd"] = device_dnd
1144
317
 
318
+ if notifications is None:
319
+ continue # notifications were not obtained, do not update
320
+
321
+ # Clear old notifications to handle cancelled ones
322
+ device.notifications = {}
323
+
1145
324
  # Update notifications
1146
325
  device_notifications = notifications.get(device.serial_number, {})
1147
326
 
@@ -1178,47 +357,42 @@ class AmazonEchoApi:
1178
357
  )
1179
358
 
1180
359
  async def _get_base_devices(self) -> None:
1181
- _, raw_resp = await self._session_request(
360
+ _, raw_resp = await self._http_wrapper.session_request(
1182
361
  method=HTTPMethod.GET,
1183
- url=f"https://alexa.amazon.{self._domain}{URI_DEVICES}",
362
+ url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DEVICES}",
1184
363
  )
1185
364
 
1186
- json_data = await self._response_to_json(raw_resp)
1187
-
1188
- _LOGGER.debug("JSON devices data: %s", scrub_fields(json_data))
1189
-
1190
- for data in json_data["devices"]:
1191
- dev_serial = data.get("serialNumber")
1192
- if not dev_serial:
1193
- _LOGGER.warning(
1194
- "Skipping device without serial number: %s", data["accountName"]
1195
- )
1196
- continue
1197
- if not self._account_owner_customer_id:
1198
- self._account_owner_customer_id = (
1199
- await self._get_account_owner_customer_id(data)
1200
- )
1201
-
1202
- if not self._account_owner_customer_id:
1203
- raise CannotRetrieveData("Cannot find account owner customer ID")
365
+ json_data = await self._http_wrapper.response_to_json(raw_resp, "devices")
1204
366
 
1205
367
  final_devices_list: dict[str, AmazonDevice] = {}
368
+ serial_to_device_type: dict[str, str] = {}
1206
369
  for device in json_data["devices"]:
1207
370
  # Remove stale, orphaned and virtual devices
1208
371
  if not device or (device.get("deviceType") in DEVICE_TO_IGNORE):
1209
372
  continue
1210
373
 
374
+ account_name: str = device["accountName"]
375
+ capabilities: list[str] = device["capabilities"]
376
+ # Skip devices that cannot be used with voice features
377
+ if "MICROPHONE" not in capabilities:
378
+ _LOGGER.debug(
379
+ "Skipping device without microphone capabilities: %s", account_name
380
+ )
381
+ continue
382
+
1211
383
  serial_number: str = device["serialNumber"]
1212
384
 
1213
385
  final_devices_list[serial_number] = AmazonDevice(
1214
- account_name=device["accountName"],
1215
- capabilities=device["capabilities"],
386
+ account_name=account_name,
387
+ capabilities=capabilities,
1216
388
  device_family=device["deviceFamily"],
1217
389
  device_type=device["deviceType"],
1218
390
  device_owner_customer_id=device["deviceOwnerCustomerId"],
1219
391
  household_device=device["deviceOwnerCustomerId"]
1220
- == self._account_owner_customer_id,
1221
- device_cluster_members=(device["clusterMembers"] or [serial_number]),
392
+ == self._session_state_data.account_customer_id,
393
+ device_cluster_members=dict.fromkeys(
394
+ device["clusterMembers"] or [serial_number]
395
+ ),
1222
396
  online=device["online"],
1223
397
  serial_number=serial_number,
1224
398
  software_version=device["softwareVersion"],
@@ -1228,37 +402,16 @@ class AmazonEchoApi:
1228
402
  notifications={},
1229
403
  )
1230
404
 
1231
- self._list_for_clusters.update(
1232
- {
1233
- device.serial_number: device.device_type
1234
- for device in final_devices_list.values()
1235
- }
1236
- )
405
+ serial_to_device_type[serial_number] = device["deviceType"]
1237
406
 
1238
- self._final_devices = final_devices_list
1239
-
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
407
+ # backfill device types for cluster members
408
+ for device in final_devices_list.values():
409
+ for member_serial in device.device_cluster_members:
410
+ device.device_cluster_members[member_serial] = (
411
+ serial_to_device_type.get(member_serial)
412
+ )
1258
413
 
1259
- authenticated = authentication.get("authenticated")
1260
- _LOGGER.debug("Session authenticated: %s", authenticated)
1261
- return bool(authenticated)
414
+ self._final_devices = final_devices_list
1262
415
 
1263
416
  def get_model_details(self, device: AmazonDevice) -> dict[str, str | None] | None:
1264
417
  """Return model datails."""
@@ -1274,294 +427,74 @@ class AmazonEchoApi:
1274
427
 
1275
428
  return model_details
1276
429
 
1277
- async def _send_message(
1278
- self,
1279
- device: AmazonDevice,
1280
- message_type: str,
1281
- message_body: str,
1282
- message_source: AmazonMusicSource | None = None,
1283
- ) -> None:
1284
- """Send message to specific device."""
1285
- if not self._login_stored_data:
1286
- _LOGGER.warning("No login data available, cannot send message")
1287
- return
1288
-
1289
- base_payload = {
1290
- "deviceType": device.device_type,
1291
- "deviceSerialNumber": device.serial_number,
1292
- "locale": self._language,
1293
- "customerId": self._account_owner_customer_id,
1294
- }
1295
-
1296
- payload: dict[str, Any]
1297
- if message_type == AmazonSequenceType.Speak:
1298
- payload = {
1299
- **base_payload,
1300
- "textToSpeak": message_body,
1301
- "target": {
1302
- "customerId": self._account_owner_customer_id,
1303
- "devices": [
1304
- {
1305
- "deviceSerialNumber": device.serial_number,
1306
- "deviceTypeId": device.device_type,
1307
- },
1308
- ],
1309
- },
1310
- "skillId": "amzn1.ask.1p.saysomething",
1311
- }
1312
- elif message_type == AmazonSequenceType.Announcement:
1313
- playback_devices: list[dict[str, str]] = [
1314
- {
1315
- "deviceSerialNumber": serial,
1316
- "deviceTypeId": self._list_for_clusters[serial],
1317
- }
1318
- for serial in device.device_cluster_members
1319
- if serial in self._list_for_clusters
1320
- ]
1321
-
1322
- payload = {
1323
- **base_payload,
1324
- "expireAfter": "PT5S",
1325
- "content": [
1326
- {
1327
- "locale": self._language,
1328
- "display": {
1329
- "title": "Home Assistant",
1330
- "body": message_body,
1331
- },
1332
- "speak": {
1333
- "type": "text",
1334
- "value": message_body,
1335
- },
1336
- }
1337
- ],
1338
- "target": {
1339
- "customerId": self._account_owner_customer_id,
1340
- "devices": playback_devices,
1341
- },
1342
- "skillId": "amzn1.ask.1p.routines.messaging",
1343
- }
1344
- elif message_type == AmazonSequenceType.Sound:
1345
- payload = {
1346
- **base_payload,
1347
- "soundStringId": message_body,
1348
- "skillId": "amzn1.ask.1p.sound",
1349
- }
1350
- elif message_type == AmazonSequenceType.Music:
1351
- payload = {
1352
- **base_payload,
1353
- "searchPhrase": message_body,
1354
- "sanitizedSearchPhrase": message_body,
1355
- "musicProviderId": message_source,
1356
- }
1357
- elif message_type == AmazonSequenceType.TextCommand:
1358
- payload = {
1359
- **base_payload,
1360
- "skillId": "amzn1.ask.1p.tellalexa",
1361
- "text": message_body,
1362
- }
1363
- elif message_type == AmazonSequenceType.LaunchSkill:
1364
- payload = {
1365
- **base_payload,
1366
- "targetDevice": {
1367
- "deviceType": device.device_type,
1368
- "deviceSerialNumber": device.serial_number,
1369
- },
1370
- "connectionRequest": {
1371
- "uri": "connection://AMAZON.Launch/" + message_body,
1372
- },
1373
- }
1374
- elif message_type in ALEXA_INFO_SKILLS:
1375
- payload = {
1376
- **base_payload,
1377
- }
1378
- else:
1379
- raise ValueError(f"Message type <{message_type}> is not recognised")
1380
-
1381
- sequence = {
1382
- "@type": "com.amazon.alexa.behaviors.model.Sequence",
1383
- "startNode": {
1384
- "@type": "com.amazon.alexa.behaviors.model.SerialNode",
1385
- "nodesToExecute": [
1386
- {
1387
- "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode", # noqa: E501
1388
- "type": message_type,
1389
- "operationPayload": payload,
1390
- },
1391
- ],
1392
- },
1393
- }
1394
-
1395
- node_data = {
1396
- "behaviorId": "PREVIEW",
1397
- "sequenceJson": orjson.dumps(sequence).decode("utf-8"),
1398
- "status": "ENABLED",
1399
- }
1400
-
1401
- _LOGGER.debug("Preview data payload: %s", node_data)
1402
- await self._session_request(
1403
- method=HTTPMethod.POST,
1404
- url=f"https://alexa.amazon.{self._domain}/api/behaviors/preview",
1405
- input_data=node_data,
1406
- json_data=True,
1407
- )
1408
-
1409
- return
1410
-
1411
430
  async def call_alexa_speak(
1412
431
  self,
1413
432
  device: AmazonDevice,
1414
- message_body: str,
433
+ text_to_speak: str,
1415
434
  ) -> None:
1416
435
  """Call Alexa.Speak to send a message."""
1417
- return await self._send_message(device, AmazonSequenceType.Speak, message_body)
436
+ await self._sequence_handler.send_message(
437
+ device, AmazonSequenceType.Speak, text_to_speak
438
+ )
1418
439
 
1419
440
  async def call_alexa_announcement(
1420
441
  self,
1421
442
  device: AmazonDevice,
1422
- message_body: str,
443
+ text_to_announce: str,
1423
444
  ) -> None:
1424
445
  """Call AlexaAnnouncement to send a message."""
1425
- return await self._send_message(
1426
- device, AmazonSequenceType.Announcement, message_body
446
+ await self._sequence_handler.send_message(
447
+ device, AmazonSequenceType.Announcement, text_to_announce
1427
448
  )
1428
449
 
1429
450
  async def call_alexa_sound(
1430
451
  self,
1431
452
  device: AmazonDevice,
1432
- message_body: str,
453
+ sound_name: str,
1433
454
  ) -> None:
1434
455
  """Call Alexa.Sound to play sound."""
1435
- return await self._send_message(device, AmazonSequenceType.Sound, message_body)
456
+ await self._sequence_handler.send_message(
457
+ device, AmazonSequenceType.Sound, sound_name
458
+ )
1436
459
 
1437
460
  async def call_alexa_music(
1438
461
  self,
1439
462
  device: AmazonDevice,
1440
- message_body: str,
1441
- message_source: AmazonMusicSource,
463
+ search_phrase: str,
464
+ music_source: AmazonMusicSource,
1442
465
  ) -> None:
1443
466
  """Call Alexa.Music.PlaySearchPhrase to play music."""
1444
- return await self._send_message(
1445
- device, AmazonSequenceType.Music, message_body, message_source
467
+ await self._sequence_handler.send_message(
468
+ device, AmazonSequenceType.Music, search_phrase, music_source
1446
469
  )
1447
470
 
1448
471
  async def call_alexa_text_command(
1449
472
  self,
1450
473
  device: AmazonDevice,
1451
- message_body: str,
474
+ text_command: str,
1452
475
  ) -> None:
1453
476
  """Call Alexa.TextCommand to issue command."""
1454
- return await self._send_message(
1455
- device, AmazonSequenceType.TextCommand, message_body
477
+ await self._sequence_handler.send_message(
478
+ device, AmazonSequenceType.TextCommand, text_command
1456
479
  )
1457
480
 
1458
481
  async def call_alexa_skill(
1459
482
  self,
1460
483
  device: AmazonDevice,
1461
- message_body: str,
484
+ skill_name: str,
1462
485
  ) -> None:
1463
486
  """Call Alexa.LaunchSkill to launch a skill."""
1464
- return await self._send_message(
1465
- device, AmazonSequenceType.LaunchSkill, message_body
487
+ await self._sequence_handler.send_message(
488
+ device, AmazonSequenceType.LaunchSkill, skill_name
1466
489
  )
1467
490
 
1468
491
  async def call_alexa_info_skill(
1469
492
  self,
1470
493
  device: AmazonDevice,
1471
- message_type: str,
494
+ info_skill_name: str,
1472
495
  ) -> None:
1473
496
  """Call Info skill. See ALEXA_INFO_SKILLS . const."""
1474
- return await self._send_message(device, message_type, "")
1475
-
1476
- async def set_do_not_disturb(self, device: AmazonDevice, state: bool) -> None:
1477
- """Set do_not_disturb flag."""
1478
- payload = {
1479
- "deviceSerialNumber": device.serial_number,
1480
- "deviceType": device.device_type,
1481
- "enabled": state,
1482
- }
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),
1521
- )
1522
-
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
- async def _get_dnd_status(self) -> dict[str, AmazonDeviceSensor]:
1546
- dnd_status: dict[str, AmazonDeviceSensor] = {}
1547
- _, raw_resp = await self._session_request(
1548
- method=HTTPMethod.GET,
1549
- url=f"https://alexa.amazon.{self._domain}{URI_DND}",
1550
- )
1551
-
1552
- dnd_data = await self._response_to_json(raw_resp)
1553
- _LOGGER.debug("DND data: %s", dnd_data)
1554
-
1555
- for dnd in dnd_data.get("doNotDisturbDeviceStatusList", {}):
1556
- dnd_status[dnd.get("deviceSerialNumber")] = AmazonDeviceSensor(
1557
- name="dnd",
1558
- value=dnd.get("enabled"),
1559
- error=False,
1560
- error_type=None,
1561
- error_msg=None,
1562
- scale=None,
1563
- )
1564
- return dnd_status
497
+ await self._sequence_handler.send_message(device, info_skill_name, "")
1565
498
 
1566
499
  async def _format_human_error(self, sensors_state: dict) -> bool:
1567
500
  """Format human readable error from malformed data."""
@@ -1577,3 +510,7 @@ class AmazonEchoApi:
1577
510
  path = error[0].get("path", "Unknown path")
1578
511
  _LOGGER.error("Error retrieving devices state: %s for path %s", msg, path)
1579
512
  return True
513
+
514
+ async def set_do_not_disturb(self, device: AmazonDevice, enable: bool) -> None:
515
+ """Set Do Not Disturb status for a device."""
516
+ await self._dnd_handler.set_do_not_disturb(device, enable)