AlexaPy 1.29.25__tar.gz → 1.30.0__tar.gz
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.
- {alexapy-1.29.25 → alexapy-1.30.0}/PKG-INFO +1 -1
- {alexapy-1.29.25 → alexapy-1.30.0}/alexapy/alexaapi.py +181 -17
- {alexapy-1.29.25 → alexapy-1.30.0}/alexapy/alexahttp2.py +61 -2
- {alexapy-1.29.25 → alexapy-1.30.0}/alexapy/alexalogin.py +148 -77
- {alexapy-1.29.25 → alexapy-1.30.0}/pyproject.toml +1 -1
- {alexapy-1.29.25 → alexapy-1.30.0}/AUTHORS.md +0 -0
- {alexapy-1.29.25 → alexapy-1.30.0}/LICENSE +0 -0
- {alexapy-1.29.25 → alexapy-1.30.0}/README.md +0 -0
- {alexapy-1.29.25 → alexapy-1.30.0}/alexapy/__init__.py +0 -0
- {alexapy-1.29.25 → alexapy-1.30.0}/alexapy/alexaproxy.py +0 -0
- {alexapy-1.29.25 → alexapy-1.30.0}/alexapy/alexawebsocket.py +0 -0
- {alexapy-1.29.25 → alexapy-1.30.0}/alexapy/const.py +0 -0
- {alexapy-1.29.25 → alexapy-1.30.0}/alexapy/errors.py +0 -0
- {alexapy-1.29.25 → alexapy-1.30.0}/alexapy/helpers.py +0 -0
|
@@ -137,9 +137,7 @@ class AlexaAPI:
|
|
|
137
137
|
cached = getattr(login, "_alexa_api_url", None)
|
|
138
138
|
if cached:
|
|
139
139
|
_LOGGER.debug(
|
|
140
|
-
"%s: Returning cached url: %s",
|
|
141
|
-
hide_email(login.email),
|
|
142
|
-
cached
|
|
140
|
+
"%s: Returning cached url: %s", hide_email(login.email), cached
|
|
143
141
|
)
|
|
144
142
|
return cached
|
|
145
143
|
|
|
@@ -159,9 +157,7 @@ class AlexaAPI:
|
|
|
159
157
|
|
|
160
158
|
try:
|
|
161
159
|
resp = await session.get(
|
|
162
|
-
endpoints_url,
|
|
163
|
-
headers=local_headers,
|
|
164
|
-
ssl=login._ssl
|
|
160
|
+
endpoints_url, headers=local_headers, ssl=login._ssl
|
|
165
161
|
)
|
|
166
162
|
text = await resp.text()
|
|
167
163
|
_LOGGER.debug(
|
|
@@ -176,10 +172,7 @@ class AlexaAPI:
|
|
|
176
172
|
if resp.status == 200:
|
|
177
173
|
try:
|
|
178
174
|
data = json.loads(text)
|
|
179
|
-
api_url = (
|
|
180
|
-
data.get("websiteApiUrl", "")
|
|
181
|
-
.rstrip("/")
|
|
182
|
-
)
|
|
175
|
+
api_url = data.get("websiteApiUrl", "").rstrip("/")
|
|
183
176
|
if api_url:
|
|
184
177
|
login._alexa_api_url = api_url
|
|
185
178
|
_LOGGER.debug(
|
|
@@ -191,7 +184,7 @@ class AlexaAPI:
|
|
|
191
184
|
else:
|
|
192
185
|
_LOGGER.debug(
|
|
193
186
|
"%s: Unable to extract websiteApiUrl from /api/endpoints",
|
|
194
|
-
hide_email(login.email)
|
|
187
|
+
hide_email(login.email),
|
|
195
188
|
)
|
|
196
189
|
except ValueError:
|
|
197
190
|
_LOGGER.debug(
|
|
@@ -516,7 +509,7 @@ class AlexaAPI:
|
|
|
516
509
|
for marker in (
|
|
517
510
|
"Rate exceeded",
|
|
518
511
|
"ThrottlingException",
|
|
519
|
-
"Too many requests"
|
|
512
|
+
"Too many requests",
|
|
520
513
|
)
|
|
521
514
|
)
|
|
522
515
|
and not getattr(login, "_rate_retry_inflight", False)
|
|
@@ -1494,6 +1487,177 @@ class AlexaAPI:
|
|
|
1494
1487
|
AlexaAPI.devices[login.email] = []
|
|
1495
1488
|
return AlexaAPI.devices[login.email]
|
|
1496
1489
|
|
|
1490
|
+
@staticmethod
|
|
1491
|
+
@_catch_all_exceptions
|
|
1492
|
+
async def get_child_mode(
|
|
1493
|
+
login: AlexaLogin, serial: str, device_type: str
|
|
1494
|
+
) -> bool | None:
|
|
1495
|
+
"""Return whether Amazon Kids (child mode) is active for a device.
|
|
1496
|
+
|
|
1497
|
+
Amazon models the Amazon Kids/child state per device as the boolean
|
|
1498
|
+
``isChildDirectedDevice``.
|
|
1499
|
+
|
|
1500
|
+
Args:
|
|
1501
|
+
login (AlexaLogin): Successfully logged in AlexaLogin
|
|
1502
|
+
serial (str): The device serial number
|
|
1503
|
+
device_type (str): The device type
|
|
1504
|
+
|
|
1505
|
+
Returns:
|
|
1506
|
+
Optional[bool]: True if Amazon Kids is active, False if not,
|
|
1507
|
+
None if the state could not be determined.
|
|
1508
|
+
|
|
1509
|
+
"""
|
|
1510
|
+
response = await AlexaAPI._static_request(
|
|
1511
|
+
"get",
|
|
1512
|
+
login,
|
|
1513
|
+
"/api/device/op-mode",
|
|
1514
|
+
query={"deviceType": device_type, "deviceSerialNumber": serial},
|
|
1515
|
+
)
|
|
1516
|
+
value, _valid = await get_json_value(response, "isChildDirectedDevice", bool)
|
|
1517
|
+
return value
|
|
1518
|
+
|
|
1519
|
+
@staticmethod
|
|
1520
|
+
@_catch_all_exceptions
|
|
1521
|
+
async def disable_child_mode(
|
|
1522
|
+
login: AlexaLogin, serial: str, device_type: str
|
|
1523
|
+
) -> None:
|
|
1524
|
+
"""Disable Amazon Kids (child mode) for a device.
|
|
1525
|
+
|
|
1526
|
+
Unassigns the device from any child profile, which turns Amazon Kids
|
|
1527
|
+
off (``isChildDirectedDevice`` becomes False).
|
|
1528
|
+
|
|
1529
|
+
Args:
|
|
1530
|
+
login (AlexaLogin): Successfully logged in AlexaLogin
|
|
1531
|
+
serial (str): The device serial number
|
|
1532
|
+
device_type (str): The device type
|
|
1533
|
+
|
|
1534
|
+
"""
|
|
1535
|
+
await AlexaAPI._static_request(
|
|
1536
|
+
"post",
|
|
1537
|
+
login,
|
|
1538
|
+
f"/api/unassign-device-from-child/{serial}/{device_type}",
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1541
|
+
@staticmethod
|
|
1542
|
+
def _parent_dashboard_subdomain(login: AlexaLogin) -> str:
|
|
1543
|
+
"""Return the region subdomain of the Amazon Kids parent dashboard.
|
|
1544
|
+
|
|
1545
|
+
Amazon serves the parental-controls dashboard from a localized
|
|
1546
|
+
subdomain (e.g. ``eltern.amazon.de`` in Germany, ``parents.amazon.com``
|
|
1547
|
+
elsewhere).
|
|
1548
|
+
"""
|
|
1549
|
+
return {"amazon.de": "eltern"}.get(login.url, "parents")
|
|
1550
|
+
|
|
1551
|
+
@staticmethod
|
|
1552
|
+
@_catch_all_exceptions
|
|
1553
|
+
async def get_child_profiles(login: AlexaLogin) -> list[dict[str, Any]] | None:
|
|
1554
|
+
"""Return the child profiles of the Amazon household.
|
|
1555
|
+
|
|
1556
|
+
Each entry contains at least ``firstName``, ``age`` and ``directedId``;
|
|
1557
|
+
the ``directedId`` is required to enable Amazon Kids on a device.
|
|
1558
|
+
|
|
1559
|
+
Args:
|
|
1560
|
+
login (AlexaLogin): Successfully logged in AlexaLogin
|
|
1561
|
+
|
|
1562
|
+
Returns:
|
|
1563
|
+
Optional[list[dict]]: The CHILD household members, or None on error.
|
|
1564
|
+
|
|
1565
|
+
"""
|
|
1566
|
+
response = await AlexaAPI._static_request(
|
|
1567
|
+
"get",
|
|
1568
|
+
login,
|
|
1569
|
+
"/ajax/get-household-with-age",
|
|
1570
|
+
sub_domain=AlexaAPI._parent_dashboard_subdomain(login),
|
|
1571
|
+
)
|
|
1572
|
+
members, valid = await get_json_value(response, "members", list)
|
|
1573
|
+
if not valid or not members:
|
|
1574
|
+
return []
|
|
1575
|
+
return [member for member in members if member.get("role") == "CHILD"]
|
|
1576
|
+
|
|
1577
|
+
@staticmethod
|
|
1578
|
+
@_catch_all_exceptions
|
|
1579
|
+
async def get_device_child(
|
|
1580
|
+
login: AlexaLogin, serial: str, device_type: str
|
|
1581
|
+
) -> str | None:
|
|
1582
|
+
"""Return the directedId of the child a device is assigned to.
|
|
1583
|
+
|
|
1584
|
+
Args:
|
|
1585
|
+
login (AlexaLogin): Successfully logged in AlexaLogin
|
|
1586
|
+
serial (str): The device serial number
|
|
1587
|
+
device_type (str): The device type
|
|
1588
|
+
|
|
1589
|
+
Returns:
|
|
1590
|
+
Optional[str]: The child directedId, or None if unassigned/unknown.
|
|
1591
|
+
|
|
1592
|
+
"""
|
|
1593
|
+
response = await AlexaAPI._static_request(
|
|
1594
|
+
"get",
|
|
1595
|
+
login,
|
|
1596
|
+
"/ajax/get-oobe-device-data",
|
|
1597
|
+
query={"deviceId": serial, "deviceTypeId": device_type},
|
|
1598
|
+
sub_domain=AlexaAPI._parent_dashboard_subdomain(login),
|
|
1599
|
+
)
|
|
1600
|
+
value, _valid = await get_json_value(response, "childDirectedId", str)
|
|
1601
|
+
return value or None
|
|
1602
|
+
|
|
1603
|
+
@staticmethod
|
|
1604
|
+
@_catch_all_exceptions
|
|
1605
|
+
async def enable_child_mode(
|
|
1606
|
+
login: AlexaLogin, serial: str, device_type: str, child_directed_id: str
|
|
1607
|
+
) -> None:
|
|
1608
|
+
"""Enable Amazon Kids by assigning a device to a child profile.
|
|
1609
|
+
|
|
1610
|
+
The parent dashboard uses its own CSRF token (``ft-panda-csrf-token``),
|
|
1611
|
+
which is seeded by loading the device onboarding page first; the token
|
|
1612
|
+
is then echoed back in the ``x-amzn-csrf`` header.
|
|
1613
|
+
|
|
1614
|
+
Args:
|
|
1615
|
+
login (AlexaLogin): Successfully logged in AlexaLogin
|
|
1616
|
+
serial (str): The device serial number
|
|
1617
|
+
device_type (str): The device type
|
|
1618
|
+
child_directed_id (str): directedId of the child (see get_child_profiles)
|
|
1619
|
+
|
|
1620
|
+
"""
|
|
1621
|
+
sub_domain = AlexaAPI._parent_dashboard_subdomain(login)
|
|
1622
|
+
# Seed the parent-dashboard CSRF cookie (ft-panda-csrf-token).
|
|
1623
|
+
await AlexaAPI._static_request(
|
|
1624
|
+
"get",
|
|
1625
|
+
login,
|
|
1626
|
+
f"/oobe/brownie/start/{device_type}/{serial}",
|
|
1627
|
+
sub_domain=sub_domain,
|
|
1628
|
+
)
|
|
1629
|
+
additional_headers = {
|
|
1630
|
+
"Referer": (
|
|
1631
|
+
f"https://{sub_domain}.{login.url}"
|
|
1632
|
+
f"/oobe/brownie/start/{device_type}/{serial}"
|
|
1633
|
+
),
|
|
1634
|
+
"Origin": f"https://{sub_domain}.{login.url}",
|
|
1635
|
+
}
|
|
1636
|
+
try:
|
|
1637
|
+
# The ft-panda-csrf-token cookie is scoped to the parent-dashboard
|
|
1638
|
+
# host, so it must be read for that host (not the default one).
|
|
1639
|
+
additional_headers["x-amzn-csrf"] = login._get_cookies_from_session(
|
|
1640
|
+
f"{sub_domain}.{login.url}"
|
|
1641
|
+
)["ft-panda-csrf-token"].value
|
|
1642
|
+
except (KeyError, AttributeError):
|
|
1643
|
+
_LOGGER.debug(
|
|
1644
|
+
"%s: ft-panda-csrf-token not found after bootstrap; "
|
|
1645
|
+
"assign may be rejected",
|
|
1646
|
+
hide_email(login.email),
|
|
1647
|
+
)
|
|
1648
|
+
await AlexaAPI._static_request(
|
|
1649
|
+
"post",
|
|
1650
|
+
login,
|
|
1651
|
+
"/ajax/assign-device-to-child",
|
|
1652
|
+
data={
|
|
1653
|
+
"deviceId": serial,
|
|
1654
|
+
"deviceTypeId": device_type,
|
|
1655
|
+
"childDirectedId": child_directed_id,
|
|
1656
|
+
},
|
|
1657
|
+
additional_headers=additional_headers,
|
|
1658
|
+
sub_domain=sub_domain,
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1497
1661
|
@staticmethod
|
|
1498
1662
|
@_catch_all_exceptions
|
|
1499
1663
|
async def get_wake_words(login: AlexaLogin) -> list[dict[str, Any]] | None:
|
|
@@ -1633,9 +1797,7 @@ class AlexaAPI:
|
|
|
1633
1797
|
|
|
1634
1798
|
record_key = (record.get("recordKey") or "").split("#")
|
|
1635
1799
|
o["deviceType"] = (
|
|
1636
|
-
record_key[2]
|
|
1637
|
-
if len(record_key) > 2 and record_key[2]
|
|
1638
|
-
else None
|
|
1800
|
+
record_key[2] if len(record_key) > 2 and record_key[2] else None
|
|
1639
1801
|
)
|
|
1640
1802
|
o["creationTimestamp"] = record.get("timestamp")
|
|
1641
1803
|
o["deviceSerialNumber"] = record_key[3] if len(record_key) > 3 else None
|
|
@@ -2072,8 +2234,10 @@ class AlexaAPI:
|
|
|
2072
2234
|
# 🔹 Use discovered base instead of hard-coded NA
|
|
2073
2235
|
api_base = await AlexaAPI._get_alexa_api_base(login)
|
|
2074
2236
|
ts = int(time.time() * 1000)
|
|
2075
|
-
url =
|
|
2076
|
-
|
|
2237
|
+
url = (
|
|
2238
|
+
URL(api_base)
|
|
2239
|
+
.with_path("/api/notifications")
|
|
2240
|
+
.update_query({"cached": "true", "_": str(ts)})
|
|
2077
2241
|
)
|
|
2078
2242
|
|
|
2079
2243
|
headers = AlexaAPI._build_notifications_headers(login)
|
|
@@ -55,9 +55,34 @@ class HTTP2EchoClient:
|
|
|
55
55
|
close_callback: Callable[[], Coroutine[Any, Any, None]],
|
|
56
56
|
error_callback: Callable[[str], Coroutine[Any, Any, None]],
|
|
57
57
|
loop: asyncio.AbstractEventLoop | None = None,
|
|
58
|
+
*,
|
|
59
|
+
read_timeout: float | None = 300,
|
|
58
60
|
) -> None:
|
|
59
|
-
"""Init for threading and HTTP2 Push Connection.
|
|
61
|
+
"""Init for threading and HTTP2 Push Connection.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
login: Authenticated AlexaLogin whose session opens the
|
|
65
|
+
directives stream.
|
|
66
|
+
msg_callback: Coroutine invoked with each directive message
|
|
67
|
+
parsed from the stream.
|
|
68
|
+
open_callback: Coroutine invoked once the HTTP2 stream has been
|
|
69
|
+
accepted and opened.
|
|
70
|
+
close_callback: Coroutine invoked when the stream is closed.
|
|
71
|
+
error_callback: Coroutine invoked with an error message when the
|
|
72
|
+
connection fails.
|
|
73
|
+
loop: Event loop to schedule tasks on; defaults to the running
|
|
74
|
+
loop when omitted.
|
|
75
|
+
read_timeout: Maximum seconds to wait between chunks of data on
|
|
76
|
+
the directives stream before treating the connection as stale
|
|
77
|
+
and closing it. A healthy stream carries multipart keepalive
|
|
78
|
+
traffic well inside this window; a stream that has gone silent
|
|
79
|
+
without a transport-level close delivers nothing and would
|
|
80
|
+
otherwise hang forever without any error. Pass None to disable
|
|
81
|
+
the check (previous behavior).
|
|
82
|
+
"""
|
|
60
83
|
assert login.session is not None
|
|
84
|
+
if read_timeout is not None and read_timeout <= 0:
|
|
85
|
+
raise ValueError("read_timeout must be positive or None")
|
|
61
86
|
self._options = {
|
|
62
87
|
"method": "GET",
|
|
63
88
|
"path": "/v20160207/directives",
|
|
@@ -81,10 +106,21 @@ class HTTP2EchoClient:
|
|
|
81
106
|
loop if loop else asyncio.get_event_loop()
|
|
82
107
|
)
|
|
83
108
|
self._last_ping = datetime.datetime(1, 1, 1)
|
|
109
|
+
self._last_activity: datetime.datetime | None = None
|
|
110
|
+
self._read_timeout = read_timeout
|
|
84
111
|
self._tasks = set()
|
|
85
112
|
self._opened: asyncio.Event = asyncio.Event()
|
|
86
113
|
self._closing: bool = False
|
|
87
114
|
|
|
115
|
+
@property
|
|
116
|
+
def last_activity(self) -> datetime.datetime | None:
|
|
117
|
+
"""Return when data last arrived on the directives stream.
|
|
118
|
+
|
|
119
|
+
Any traffic counts, including multipart keepalive boundaries, not
|
|
120
|
+
only parsed directives. None until the first chunk arrives.
|
|
121
|
+
"""
|
|
122
|
+
return self._last_activity
|
|
123
|
+
|
|
88
124
|
async def async_run(self) -> None:
|
|
89
125
|
"""Start Async WebSocket Listener.
|
|
90
126
|
|
|
@@ -133,7 +169,11 @@ class HTTP2EchoClient:
|
|
|
133
169
|
headers={
|
|
134
170
|
"authorization": self._options["authorization"],
|
|
135
171
|
},
|
|
136
|
-
|
|
172
|
+
# Never time out the overall stream, but bound the gap between
|
|
173
|
+
# chunks: the server sends keepalive boundaries continuously,
|
|
174
|
+
# so a long silent gap means the connection is dead even if
|
|
175
|
+
# the transport still looks open.
|
|
176
|
+
timeout=httpx.Timeout(None, read=self._read_timeout),
|
|
137
177
|
) as response:
|
|
138
178
|
# Validate the stream was accepted before reporting "open" upstream.
|
|
139
179
|
if response.status_code in (401, 403):
|
|
@@ -164,6 +204,24 @@ class HTTP2EchoClient:
|
|
|
164
204
|
|
|
165
205
|
# Otherwise, normal disconnect.
|
|
166
206
|
return
|
|
207
|
+
except httpx.ReadTimeout:
|
|
208
|
+
# No data (not even keepalives) inside the read window: the
|
|
209
|
+
# stream has gone silent without a transport-level close. Handle
|
|
210
|
+
# it so the consumer reconnects instead of hanging forever.
|
|
211
|
+
_LOGGER.debug(
|
|
212
|
+
"HTTP2 stream silent for %ss; closing stale connection",
|
|
213
|
+
self._read_timeout,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# If we never reached "open", propagate so the caller waiting on
|
|
217
|
+
# open sees a failed connect. The shared finally below still
|
|
218
|
+
# notifies close once, as it does for the other error paths.
|
|
219
|
+
if not self._opened.is_set():
|
|
220
|
+
raise
|
|
221
|
+
|
|
222
|
+
# Otherwise, treat like a normal disconnect so reconnect runs.
|
|
223
|
+
self.on_close(f"HTTP2 stream silent for {self._read_timeout}s")
|
|
224
|
+
return
|
|
167
225
|
except Exception as exception_:
|
|
168
226
|
# Surface unexpected failures so callers waiting for open can fail fast.
|
|
169
227
|
_LOGGER.debug("HTTP2 exception: %s", exception_)
|
|
@@ -178,6 +236,7 @@ class HTTP2EchoClient:
|
|
|
178
236
|
"""Handle New Message."""
|
|
179
237
|
reauth_required = "Unable to authenticate the request. Please provide a valid authorization token." # noqa: E501
|
|
180
238
|
_LOGGER.debug("Received raw message: %s", message)
|
|
239
|
+
self._last_activity = datetime.datetime.now(datetime.UTC)
|
|
181
240
|
for line in message.splitlines():
|
|
182
241
|
if line.startswith("------"):
|
|
183
242
|
if not self.boundary: # set boundary character
|
|
@@ -703,6 +703,12 @@ class AlexaLogin:
|
|
|
703
703
|
try:
|
|
704
704
|
async with aiofiles.open(cookiefile, "rb") as myfile:
|
|
705
705
|
raw_cookie_file = await myfile.read()
|
|
706
|
+
if not raw_cookie_file:
|
|
707
|
+
_LOGGER.debug(
|
|
708
|
+
"Cookie file %s is empty; ignoring it",
|
|
709
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
710
|
+
)
|
|
711
|
+
continue
|
|
706
712
|
try:
|
|
707
713
|
cookies = loads(raw_cookie_file.decode())
|
|
708
714
|
if self._debug:
|
|
@@ -880,95 +886,114 @@ class AlexaLogin:
|
|
|
880
886
|
pass
|
|
881
887
|
return data
|
|
882
888
|
|
|
883
|
-
async def test_loggedin(
|
|
884
|
-
|
|
889
|
+
async def test_loggedin(
|
|
890
|
+
self,
|
|
891
|
+
cookies: dict[str, str] | None = None,
|
|
892
|
+
*,
|
|
893
|
+
rebuild_session: bool = True,
|
|
894
|
+
) -> bool:
|
|
895
|
+
"""Test whether the connection is logged in.
|
|
896
|
+
|
|
897
|
+
Attempts to retrieve the authenticated customer and compare the returned
|
|
898
|
+
email address with the expected login email.
|
|
885
899
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
Returns false if unsuccessful getting json or the emails don't match
|
|
889
|
-
Returns false if no csrf found; necessary to issue commands
|
|
900
|
+
Returns False if the response cannot be decoded, the email does not match,
|
|
901
|
+
or no usable authenticated session can be confirmed.
|
|
890
902
|
"""
|
|
891
903
|
if self._debug:
|
|
892
|
-
_LOGGER.debug(
|
|
904
|
+
_LOGGER.debug(
|
|
905
|
+
"Testing whether logged in to alexa.%s (rebuild_session=%s)",
|
|
906
|
+
self._url,
|
|
907
|
+
rebuild_session
|
|
908
|
+
)
|
|
893
909
|
_LOGGER.debug("Cookies: %s", cookies)
|
|
894
910
|
_LOGGER.debug("Session Cookies:\n%s", self._print_session_cookies())
|
|
895
911
|
_LOGGER.debug("Header: %s", dumps(self._headers))
|
|
912
|
+
|
|
896
913
|
if not self._session:
|
|
897
914
|
self._create_session()
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
self.
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
)
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
email = None
|
|
914
|
-
json = None
|
|
915
|
-
await self._process_resp(get_resp)
|
|
916
|
-
try:
|
|
917
|
-
json = await get_resp.json()
|
|
918
|
-
email = json.get("email")
|
|
919
|
-
except (JSONDecodeError, SimpleJSONDecodeError, ContentTypeError) as ex:
|
|
920
|
-
_LOGGER.debug(
|
|
921
|
-
"Not logged in: %s",
|
|
922
|
-
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
923
|
-
)
|
|
924
|
-
if self.url.lower() == "amazon.com":
|
|
925
|
-
return False
|
|
926
|
-
# Convert from amazon.com domain to native domain
|
|
927
|
-
if self.url.lower() != "amazon.com":
|
|
928
|
-
self._headers["authority"] = f"www.{self._url}"
|
|
915
|
+
|
|
916
|
+
if rebuild_session:
|
|
917
|
+
await self.get_tokens()
|
|
918
|
+
await self.register_capabilities()
|
|
919
|
+
await self.exchange_token_for_cookies()
|
|
920
|
+
await self.get_csrf()
|
|
921
|
+
|
|
922
|
+
domains = [self._url]
|
|
923
|
+
if self._url.lower() != "amazon.com":
|
|
924
|
+
domains.append("amazon.com")
|
|
925
|
+
|
|
926
|
+
customer = None
|
|
927
|
+
|
|
928
|
+
for domain in domains:
|
|
929
|
+
self._headers["authority"] = f"www.{domain}"
|
|
929
930
|
path = (
|
|
930
931
|
self._prefix
|
|
931
|
-
+
|
|
932
|
+
+ domain
|
|
932
933
|
+ f"/api/users/me?platform=ios&version={CALL_VERSION}"
|
|
933
934
|
)
|
|
935
|
+
|
|
934
936
|
self._log_cookies_for_url(path)
|
|
935
|
-
get_resp = await self._session.get(
|
|
937
|
+
get_resp = await self._session.get(
|
|
938
|
+
path,
|
|
939
|
+
cookies=cookies,
|
|
940
|
+
ssl=self._ssl,
|
|
941
|
+
)
|
|
936
942
|
await self._process_resp(get_resp)
|
|
943
|
+
|
|
937
944
|
try:
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
945
|
+
customer = await get_resp.json()
|
|
946
|
+
except (
|
|
947
|
+
JSONDecodeError,
|
|
948
|
+
SimpleJSONDecodeError,
|
|
949
|
+
ContentTypeError,
|
|
950
|
+
) as ex:
|
|
941
951
|
_LOGGER.debug(
|
|
942
|
-
"Not logged in: %s",
|
|
952
|
+
"Not logged in to %s: %s",
|
|
953
|
+
domain,
|
|
943
954
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
944
955
|
)
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
if "@" in self.email:
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
)
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
956
|
+
continue
|
|
957
|
+
|
|
958
|
+
email = customer.get("email")
|
|
959
|
+
if (email and email.lower() == self.email.lower()) or "@" not in self.email:
|
|
960
|
+
break
|
|
961
|
+
|
|
962
|
+
_LOGGER.debug(
|
|
963
|
+
"Login email from %s did not match stored email: %s",
|
|
964
|
+
domain,
|
|
965
|
+
hide_email(email),
|
|
966
|
+
)
|
|
967
|
+
customer = None
|
|
968
|
+
|
|
969
|
+
if customer is None:
|
|
970
|
+
_LOGGER.debug("Not logged in to any attempted Alexa domain")
|
|
971
|
+
await self.reset()
|
|
972
|
+
return False
|
|
973
|
+
|
|
974
|
+
email = customer.get("email")
|
|
975
|
+
self.customer_id = customer.get("id")
|
|
976
|
+
|
|
977
|
+
if "@" in self.email:
|
|
978
|
+
_LOGGER.debug(
|
|
979
|
+
"Logged in as %s to %s with id: %s",
|
|
980
|
+
hide_email(email),
|
|
981
|
+
self.url,
|
|
982
|
+
self.customer_id,
|
|
983
|
+
)
|
|
984
|
+
else:
|
|
985
|
+
_LOGGER.debug(
|
|
986
|
+
"Logged in as %s to mobile account %s with %s",
|
|
987
|
+
hide_email(email),
|
|
988
|
+
self.url,
|
|
989
|
+
self.customer_id,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
self.stats["login_timestamp"] = datetime.datetime.now()
|
|
993
|
+
self.stats["api_calls"] = 0
|
|
994
|
+
await self.check_domain()
|
|
995
|
+
await self.finalize_login()
|
|
996
|
+
return True
|
|
972
997
|
|
|
973
998
|
async def get_csrf_token(self) -> str | None:
|
|
974
999
|
"""Get an anti-CSRF token from an Amazon webpage."""
|
|
@@ -1045,11 +1070,44 @@ class AlexaLogin:
|
|
|
1045
1070
|
) -> None:
|
|
1046
1071
|
"""Login to Amazon."""
|
|
1047
1072
|
data = data or {}
|
|
1073
|
+
|
|
1074
|
+
# Prefer the persisted OAuth device registration whenever a refresh token
|
|
1075
|
+
# is available. Access tokens and website cookies are short-lived runtime
|
|
1076
|
+
# credentials and can be rebuilt silently from the refresh token without
|
|
1077
|
+
# device registration, capability registration, or stored credentials.
|
|
1078
|
+
if self.refresh_token:
|
|
1079
|
+
_LOGGER.debug("Attempting login using the stored OAuth refresh token")
|
|
1080
|
+
if not self._session:
|
|
1081
|
+
self._create_session()
|
|
1082
|
+
recovered = (
|
|
1083
|
+
await self.refresh_access_token()
|
|
1084
|
+
and await self.exchange_token_for_cookies()
|
|
1085
|
+
and await self.get_csrf()
|
|
1086
|
+
and await self.test_loggedin(rebuild_session=False)
|
|
1087
|
+
)
|
|
1088
|
+
if recovered:
|
|
1089
|
+
_LOGGER.info(
|
|
1090
|
+
"Logged in %s using the stored OAuth refresh token",
|
|
1091
|
+
hide_email(self.email),
|
|
1092
|
+
)
|
|
1093
|
+
return
|
|
1094
|
+
_LOGGER.debug(
|
|
1095
|
+
"OAuth refresh-token login failed; falling back to stored cookies"
|
|
1096
|
+
)
|
|
1097
|
+
await self.reset()
|
|
1098
|
+
|
|
1099
|
+
# Retain persisted-cookie login as a compatibility fallback for accounts
|
|
1100
|
+
# without a usable OAuth refresh token.
|
|
1048
1101
|
if cookies:
|
|
1049
|
-
_LOGGER.debug("Using cookies to log in")
|
|
1050
|
-
if await self.test_loggedin(
|
|
1102
|
+
_LOGGER.debug("Using stored cookies to log in")
|
|
1103
|
+
if await self.test_loggedin(
|
|
1104
|
+
cookies,
|
|
1105
|
+
rebuild_session=False,
|
|
1106
|
+
):
|
|
1051
1107
|
return
|
|
1108
|
+
_LOGGER.debug("Stored-cookie login failed; falling back to credentials")
|
|
1052
1109
|
await self.reset()
|
|
1110
|
+
|
|
1053
1111
|
_LOGGER.debug("Using credentials to log in")
|
|
1054
1112
|
if not self._site:
|
|
1055
1113
|
site: URL = self.start_url
|
|
@@ -1212,18 +1270,31 @@ class AlexaLogin:
|
|
|
1212
1270
|
assert isinstance(cookie_jar, aiohttp.CookieJar)
|
|
1213
1271
|
if self._debug:
|
|
1214
1272
|
_LOGGER.debug("Saving cookie to %s", cookiefile)
|
|
1273
|
+
temp_cookiefile = f"{self._cookiefile[0]}.{uuid4().hex}.tmp"
|
|
1215
1274
|
try:
|
|
1216
1275
|
serialized_cookie_jar = _serialize_cookie_jar(cookie_jar)
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
await localfile.
|
|
1276
|
+
serialized_cookies = dumps(serialized_cookie_jar)
|
|
1277
|
+
async with aiofiles.open(temp_cookiefile, mode="w") as localfile:
|
|
1278
|
+
await localfile.write(serialized_cookies)
|
|
1279
|
+
await localfile.flush()
|
|
1280
|
+
|
|
1281
|
+
# The temporary file is created beside the destination so
|
|
1282
|
+
# os.replace() is atomic on the same filesystem. The live
|
|
1283
|
+
# cookie file therefore remains valid until the replacement
|
|
1284
|
+
# is complete, even if shutdown interrupts the write.
|
|
1285
|
+
await asyncio.to_thread(
|
|
1286
|
+
os.replace, temp_cookiefile, self._cookiefile[0]
|
|
1287
|
+
)
|
|
1221
1288
|
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
1222
1289
|
_LOGGER.debug(
|
|
1223
1290
|
"Error saving serialized cookie to %s: %s",
|
|
1224
1291
|
self._cookiefile[0].replace(self.email, hide_email(self.email)),
|
|
1225
1292
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
1226
1293
|
)
|
|
1294
|
+
finally:
|
|
1295
|
+
with contextlib.suppress(OSError):
|
|
1296
|
+
if os.path.exists(temp_cookiefile):
|
|
1297
|
+
await aioos.remove(temp_cookiefile)
|
|
1227
1298
|
elif (cookiefile) and os.path.exists(cookiefile):
|
|
1228
1299
|
_LOGGER.debug(
|
|
1229
1300
|
"Removing outdated cookiefile %s",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|