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