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/__init__.py +1 -1
- aioamazondevices/api.py +150 -1213
- aioamazondevices/const/__init__.py +1 -0
- aioamazondevices/{const.py → const/devices.py} +54 -161
- aioamazondevices/const/http.py +37 -0
- aioamazondevices/const/metadata.py +46 -0
- aioamazondevices/{query.py → const/queries.py} +1 -1
- aioamazondevices/const/schedules.py +61 -0
- aioamazondevices/{sounds.py → const/sounds.py} +2 -1
- aioamazondevices/http_wrapper.py +422 -0
- aioamazondevices/implementation/__init__.py +1 -0
- aioamazondevices/implementation/dnd.py +56 -0
- aioamazondevices/implementation/notification.py +224 -0
- aioamazondevices/implementation/sequence.py +159 -0
- aioamazondevices/login.py +439 -0
- aioamazondevices/structures.py +65 -0
- aioamazondevices/utils.py +23 -1
- {aioamazondevices-6.5.1.dist-info → aioamazondevices-11.0.2.dist-info}/METADATA +17 -4
- aioamazondevices-11.0.2.dist-info/RECORD +23 -0
- aioamazondevices-6.5.1.dist-info/RECORD +0 -12
- {aioamazondevices-6.5.1.dist-info → aioamazondevices-11.0.2.dist-info}/WHEEL +0 -0
- {aioamazondevices-6.5.1.dist-info → aioamazondevices-11.0.2.dist-info}/licenses/LICENSE +0 -0
aioamazondevices/api.py
CHANGED
|
@@ -1,143 +1,47 @@
|
|
|
1
1
|
"""Support for Amazon devices."""
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import base64
|
|
5
|
-
import hashlib
|
|
6
|
-
import mimetypes
|
|
7
|
-
import secrets
|
|
8
|
-
import uuid
|
|
9
|
-
from dataclasses import dataclass
|
|
3
|
+
from collections.abc import Callable, Coroutine
|
|
10
4
|
from datetime import UTC, datetime, timedelta
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
|
|
14
|
-
from
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 .
|
|
80
|
-
from .
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
163
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
517
|
-
self
|
|
518
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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.
|
|
130
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
626
131
|
method=HTTPMethod.POST,
|
|
627
|
-
url=f"https://alexa.amazon.{self.
|
|
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.
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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.
|
|
240
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
734
241
|
method=HTTPMethod.POST,
|
|
735
|
-
url=f"https://alexa.amazon.{self.
|
|
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.
|
|
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.
|
|
1133
|
-
notifications = await self.
|
|
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
|
|
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.
|
|
360
|
+
_, raw_resp = await self._http_wrapper.session_request(
|
|
1182
361
|
method=HTTPMethod.GET,
|
|
1183
|
-
url=f"https://alexa.amazon.{self.
|
|
362
|
+
url=f"https://alexa.amazon.{self._session_state_data.domain}{URI_DEVICES}",
|
|
1184
363
|
)
|
|
1185
364
|
|
|
1186
|
-
json_data = await self.
|
|
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=
|
|
1215
|
-
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.
|
|
1221
|
-
device_cluster_members=(
|
|
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
|
-
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
433
|
+
text_to_speak: str,
|
|
1415
434
|
) -> None:
|
|
1416
435
|
"""Call Alexa.Speak to send a message."""
|
|
1417
|
-
|
|
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
|
-
|
|
443
|
+
text_to_announce: str,
|
|
1423
444
|
) -> None:
|
|
1424
445
|
"""Call AlexaAnnouncement to send a message."""
|
|
1425
|
-
|
|
1426
|
-
device, AmazonSequenceType.Announcement,
|
|
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
|
-
|
|
453
|
+
sound_name: str,
|
|
1433
454
|
) -> None:
|
|
1434
455
|
"""Call Alexa.Sound to play sound."""
|
|
1435
|
-
|
|
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
|
-
|
|
1441
|
-
|
|
463
|
+
search_phrase: str,
|
|
464
|
+
music_source: AmazonMusicSource,
|
|
1442
465
|
) -> None:
|
|
1443
466
|
"""Call Alexa.Music.PlaySearchPhrase to play music."""
|
|
1444
|
-
|
|
1445
|
-
device, AmazonSequenceType.Music,
|
|
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
|
-
|
|
474
|
+
text_command: str,
|
|
1452
475
|
) -> None:
|
|
1453
476
|
"""Call Alexa.TextCommand to issue command."""
|
|
1454
|
-
|
|
1455
|
-
device, AmazonSequenceType.TextCommand,
|
|
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
|
-
|
|
484
|
+
skill_name: str,
|
|
1462
485
|
) -> None:
|
|
1463
486
|
"""Call Alexa.LaunchSkill to launch a skill."""
|
|
1464
|
-
|
|
1465
|
-
device, AmazonSequenceType.LaunchSkill,
|
|
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
|
-
|
|
494
|
+
info_skill_name: str,
|
|
1472
495
|
) -> None:
|
|
1473
496
|
"""Call Info skill. See ALEXA_INFO_SKILLS . const."""
|
|
1474
|
-
|
|
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)
|