AlexaPy 1.29.24__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.24 → alexapy-1.30.0}/PKG-INFO +1 -1
- {alexapy-1.29.24 → alexapy-1.30.0}/alexapy/alexaapi.py +197 -19
- {alexapy-1.29.24 → alexapy-1.30.0}/alexapy/alexahttp2.py +61 -2
- {alexapy-1.29.24 → alexapy-1.30.0}/alexapy/alexalogin.py +195 -97
- {alexapy-1.29.24 → alexapy-1.30.0}/pyproject.toml +1 -1
- {alexapy-1.29.24 → alexapy-1.30.0}/AUTHORS.md +0 -0
- {alexapy-1.29.24 → alexapy-1.30.0}/LICENSE +0 -0
- {alexapy-1.29.24 → alexapy-1.30.0}/README.md +0 -0
- {alexapy-1.29.24 → alexapy-1.30.0}/alexapy/__init__.py +0 -0
- {alexapy-1.29.24 → alexapy-1.30.0}/alexapy/alexaproxy.py +0 -0
- {alexapy-1.29.24 → alexapy-1.30.0}/alexapy/alexawebsocket.py +0 -0
- {alexapy-1.29.24 → alexapy-1.30.0}/alexapy/const.py +0 -0
- {alexapy-1.29.24 → alexapy-1.30.0}/alexapy/errors.py +0 -0
- {alexapy-1.29.24 → 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)
|
|
@@ -1475,11 +1468,196 @@ class AlexaAPI:
|
|
|
1475
1468
|
"get", login, "/api/devices-v2/device", query=None
|
|
1476
1469
|
)
|
|
1477
1470
|
devices, *_ = await get_json_value(response, "devices", list)
|
|
1478
|
-
|
|
1479
|
-
|
|
1471
|
+
|
|
1472
|
+
if devices is not None:
|
|
1473
|
+
AlexaAPI.devices[login.email] = devices
|
|
1474
|
+
return devices
|
|
1475
|
+
|
|
1476
|
+
if login.email in AlexaAPI.devices:
|
|
1477
|
+
_LOGGER.debug(
|
|
1478
|
+
"%s: Using cached device list because device API returned no data",
|
|
1479
|
+
hide_email(login.email),
|
|
1480
|
+
)
|
|
1481
|
+
return AlexaAPI.devices[login.email]
|
|
1482
|
+
|
|
1483
|
+
_LOGGER.warning(
|
|
1484
|
+
"%s: Device API returned no data and no cached device list is available",
|
|
1485
|
+
hide_email(login.email),
|
|
1480
1486
|
)
|
|
1487
|
+
AlexaAPI.devices[login.email] = []
|
|
1481
1488
|
return AlexaAPI.devices[login.email]
|
|
1482
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
|
+
|
|
1483
1661
|
@staticmethod
|
|
1484
1662
|
@_catch_all_exceptions
|
|
1485
1663
|
async def get_wake_words(login: AlexaLogin) -> list[dict[str, Any]] | None:
|
|
@@ -1619,9 +1797,7 @@ class AlexaAPI:
|
|
|
1619
1797
|
|
|
1620
1798
|
record_key = (record.get("recordKey") or "").split("#")
|
|
1621
1799
|
o["deviceType"] = (
|
|
1622
|
-
record_key[2]
|
|
1623
|
-
if len(record_key) > 2 and record_key[2]
|
|
1624
|
-
else None
|
|
1800
|
+
record_key[2] if len(record_key) > 2 and record_key[2] else None
|
|
1625
1801
|
)
|
|
1626
1802
|
o["creationTimestamp"] = record.get("timestamp")
|
|
1627
1803
|
o["deviceSerialNumber"] = record_key[3] if len(record_key) > 3 else None
|
|
@@ -2058,8 +2234,10 @@ class AlexaAPI:
|
|
|
2058
2234
|
# 🔹 Use discovered base instead of hard-coded NA
|
|
2059
2235
|
api_base = await AlexaAPI._get_alexa_api_base(login)
|
|
2060
2236
|
ts = int(time.time() * 1000)
|
|
2061
|
-
url =
|
|
2062
|
-
|
|
2237
|
+
url = (
|
|
2238
|
+
URL(api_base)
|
|
2239
|
+
.with_path("/api/notifications")
|
|
2240
|
+
.update_query({"cached": "true", "_": str(ts)})
|
|
2063
2241
|
)
|
|
2064
2242
|
|
|
2065
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
|
|
@@ -63,6 +63,17 @@ from .helpers import (
|
|
|
63
63
|
_LOGGER = logging.getLogger(__name__)
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
# Ensure http.cookies.Morsel recognizes the Partitioned cookie attribute.
|
|
67
|
+
# Older Python releases do not include the "partitioned" attribute.
|
|
68
|
+
# Newer releases (Python 3.14+) already support it, so this patch is only
|
|
69
|
+
# applied when necessary.
|
|
70
|
+
if "partitioned" not in Morsel._reserved:
|
|
71
|
+
_LOGGER.debug("Adding partitioned support to http.cookies.Morsel")
|
|
72
|
+
Morsel._reserved["partitioned"] = "Partitioned"
|
|
73
|
+
|
|
74
|
+
Morsel._flags.add("partitioned")
|
|
75
|
+
|
|
76
|
+
|
|
66
77
|
def create_alexa_context() -> ssl.SSLContext:
|
|
67
78
|
"""Create an SSL context for Alexa."""
|
|
68
79
|
context = ssl.create_default_context(
|
|
@@ -203,9 +214,12 @@ def _serialize_cookie_jar(cookie_jar: aiohttp.CookieJar) -> dict[str, Any]:
|
|
|
203
214
|
|
|
204
215
|
|
|
205
216
|
def _deserialize_cookie(
|
|
206
|
-
cookie_jar: aiohttp.CookieJar,
|
|
217
|
+
cookie_jar: aiohttp.CookieJar,
|
|
218
|
+
cookie: dict[str, Any],
|
|
219
|
+
*,
|
|
220
|
+
fallback_domain: str = "",
|
|
207
221
|
) -> tuple[str, str] | None:
|
|
208
|
-
"""Restore
|
|
222
|
+
"""Restore a serialized cookie into an aiohttp CookieJar."""
|
|
209
223
|
name = str(cookie.get("name") or "")
|
|
210
224
|
value = cookie.get("value")
|
|
211
225
|
domain = str(cookie.get("domain") or "")
|
|
@@ -221,12 +235,15 @@ def _deserialize_cookie(
|
|
|
221
235
|
if not name or value is None:
|
|
222
236
|
return None
|
|
223
237
|
|
|
238
|
+
if partitioned:
|
|
239
|
+
secure = True
|
|
240
|
+
|
|
224
241
|
cookie_value = _strip_wrapping_quotes(str(value))
|
|
225
242
|
raw_cookie = SimpleCookie()
|
|
226
243
|
raw_cookie[name] = cookie_value
|
|
227
244
|
morsel = raw_cookie[name]
|
|
228
245
|
|
|
229
|
-
clean_domain = domain.lstrip(".")
|
|
246
|
+
clean_domain = domain.lstrip(".") or fallback_domain.lstrip(".")
|
|
230
247
|
clean_path = path
|
|
231
248
|
if not clean_path.startswith("/"):
|
|
232
249
|
clean_path = f"/{clean_path}"
|
|
@@ -255,7 +272,6 @@ def _deserialize_cookie(
|
|
|
255
272
|
with contextlib.suppress(KeyError):
|
|
256
273
|
morsel["samesite"] = samesite
|
|
257
274
|
if partitioned:
|
|
258
|
-
# Only Python/aiohttp combinations that know the attr should keep it.
|
|
259
275
|
with contextlib.suppress(KeyError):
|
|
260
276
|
morsel["partitioned"] = True
|
|
261
277
|
|
|
@@ -264,7 +280,10 @@ def _deserialize_cookie(
|
|
|
264
280
|
|
|
265
281
|
|
|
266
282
|
def _deserialize_cookie_jar(
|
|
267
|
-
cookie_jar: aiohttp.CookieJar,
|
|
283
|
+
cookie_jar: aiohttp.CookieJar,
|
|
284
|
+
serialized: dict[str, Any],
|
|
285
|
+
*,
|
|
286
|
+
fallback_domain: str = "",
|
|
268
287
|
) -> dict[str, str]:
|
|
269
288
|
"""Restore a serialized alexapy cookie jar into an aiohttp CookieJar."""
|
|
270
289
|
return_cookies: dict[str, str] = {}
|
|
@@ -277,7 +296,11 @@ def _deserialize_cookie_jar(
|
|
|
277
296
|
for cookie in serialized.get("cookies", []):
|
|
278
297
|
if not isinstance(cookie, dict):
|
|
279
298
|
continue
|
|
280
|
-
restored = _deserialize_cookie(
|
|
299
|
+
restored = _deserialize_cookie(
|
|
300
|
+
cookie_jar,
|
|
301
|
+
cookie,
|
|
302
|
+
fallback_domain=fallback_domain,
|
|
303
|
+
)
|
|
281
304
|
if restored:
|
|
282
305
|
return_cookies[restored[0]] = restored[1]
|
|
283
306
|
|
|
@@ -294,7 +317,10 @@ def _legacy_cookie_value(value: Any) -> Any:
|
|
|
294
317
|
|
|
295
318
|
|
|
296
319
|
def _restore_legacy_aiohttp_cookie_mapping(
|
|
297
|
-
cookie_jar: aiohttp.CookieJar,
|
|
320
|
+
cookie_jar: aiohttp.CookieJar,
|
|
321
|
+
cookies: dict[Any, Any],
|
|
322
|
+
*,
|
|
323
|
+
fallback_domain: str = "",
|
|
298
324
|
) -> dict[str, str]:
|
|
299
325
|
"""Restore old aiohttp.CookieJar.save() mappings.
|
|
300
326
|
|
|
@@ -304,7 +330,7 @@ def _restore_legacy_aiohttp_cookie_mapping(
|
|
|
304
330
|
restored_count = 0
|
|
305
331
|
|
|
306
332
|
for bucket_key, bucket in cookies.items():
|
|
307
|
-
domain =
|
|
333
|
+
domain = fallback_domain
|
|
308
334
|
path = "/"
|
|
309
335
|
if isinstance(bucket_key, tuple):
|
|
310
336
|
domain = str(bucket_key[0] or "") if len(bucket_key) >= 1 else ""
|
|
@@ -362,6 +388,7 @@ def _restore_legacy_aiohttp_cookie_mapping(
|
|
|
362
388
|
"samesite": samesite,
|
|
363
389
|
"partitioned": partitioned,
|
|
364
390
|
},
|
|
391
|
+
fallback_domain=fallback_domain,
|
|
365
392
|
)
|
|
366
393
|
if restored:
|
|
367
394
|
restored_count += 1
|
|
@@ -676,6 +703,12 @@ class AlexaLogin:
|
|
|
676
703
|
try:
|
|
677
704
|
async with aiofiles.open(cookiefile, "rb") as myfile:
|
|
678
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
|
|
679
712
|
try:
|
|
680
713
|
cookies = loads(raw_cookie_file.decode())
|
|
681
714
|
if self._debug:
|
|
@@ -718,7 +751,11 @@ class AlexaLogin:
|
|
|
718
751
|
):
|
|
719
752
|
_LOGGER.debug("Loading serialized alexapy cookie jar")
|
|
720
753
|
cookie_jar = self._session.cookie_jar
|
|
721
|
-
return_cookies = _deserialize_cookie_jar(
|
|
754
|
+
return_cookies = _deserialize_cookie_jar(
|
|
755
|
+
cookie_jar,
|
|
756
|
+
cookies,
|
|
757
|
+
fallback_domain=self.url,
|
|
758
|
+
)
|
|
722
759
|
numcookies = len(return_cookies)
|
|
723
760
|
elif isinstance(cookies, RequestsCookieJar):
|
|
724
761
|
_LOGGER.debug("Loading RequestsCookieJar")
|
|
@@ -726,15 +763,15 @@ class AlexaLogin:
|
|
|
726
763
|
for key, value in requests_cookies.items():
|
|
727
764
|
if self._debug:
|
|
728
765
|
_LOGGER.debug('Key: "%s", Value: "%s"', key, value)
|
|
729
|
-
|
|
730
|
-
if key != "partitioned":
|
|
731
|
-
return_cookies[str(key)] = value.strip('"')
|
|
766
|
+
return_cookies[str(key)] = value.strip('"')
|
|
732
767
|
numcookies = len(return_cookies)
|
|
733
768
|
elif isinstance(cookies, defaultdict):
|
|
734
769
|
_LOGGER.debug("Loading legacy aiohttp cookie mapping")
|
|
735
770
|
cookie_jar = self._session.cookie_jar
|
|
736
771
|
return_cookies = _restore_legacy_aiohttp_cookie_mapping(
|
|
737
|
-
cookie_jar,
|
|
772
|
+
cookie_jar,
|
|
773
|
+
cookies,
|
|
774
|
+
fallback_domain=self.url,
|
|
738
775
|
)
|
|
739
776
|
numcookies = len(return_cookies)
|
|
740
777
|
elif isinstance(cookies, dict):
|
|
@@ -743,14 +780,15 @@ class AlexaLogin:
|
|
|
743
780
|
return_cookies = {
|
|
744
781
|
str(key): str(value).strip('"')
|
|
745
782
|
for key, value in cookies.items()
|
|
746
|
-
if key != "partitioned"
|
|
747
783
|
}
|
|
748
784
|
numcookies = len(return_cookies)
|
|
749
785
|
else:
|
|
750
786
|
_LOGGER.debug("Loading legacy aiohttp cookie dict mapping")
|
|
751
787
|
cookie_jar = self._session.cookie_jar
|
|
752
788
|
return_cookies = _restore_legacy_aiohttp_cookie_mapping(
|
|
753
|
-
cookie_jar,
|
|
789
|
+
cookie_jar,
|
|
790
|
+
cookies,
|
|
791
|
+
fallback_domain=self.url,
|
|
754
792
|
)
|
|
755
793
|
numcookies = len(return_cookies)
|
|
756
794
|
elif isinstance(cookies, http.cookiejar.MozillaCookieJar):
|
|
@@ -848,95 +886,114 @@ class AlexaLogin:
|
|
|
848
886
|
pass
|
|
849
887
|
return data
|
|
850
888
|
|
|
851
|
-
async def test_loggedin(
|
|
852
|
-
|
|
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.
|
|
853
899
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
Returns false if unsuccessful getting json or the emails don't match
|
|
857
|
-
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.
|
|
858
902
|
"""
|
|
859
903
|
if self._debug:
|
|
860
|
-
_LOGGER.debug(
|
|
904
|
+
_LOGGER.debug(
|
|
905
|
+
"Testing whether logged in to alexa.%s (rebuild_session=%s)",
|
|
906
|
+
self._url,
|
|
907
|
+
rebuild_session
|
|
908
|
+
)
|
|
861
909
|
_LOGGER.debug("Cookies: %s", cookies)
|
|
862
910
|
_LOGGER.debug("Session Cookies:\n%s", self._print_session_cookies())
|
|
863
911
|
_LOGGER.debug("Header: %s", dumps(self._headers))
|
|
912
|
+
|
|
864
913
|
if not self._session:
|
|
865
914
|
self._create_session()
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
self.
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
)
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
email = None
|
|
882
|
-
json = None
|
|
883
|
-
await self._process_resp(get_resp)
|
|
884
|
-
try:
|
|
885
|
-
json = await get_resp.json()
|
|
886
|
-
email = json.get("email")
|
|
887
|
-
except (JSONDecodeError, SimpleJSONDecodeError, ContentTypeError) as ex:
|
|
888
|
-
_LOGGER.debug(
|
|
889
|
-
"Not logged in: %s",
|
|
890
|
-
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
891
|
-
)
|
|
892
|
-
if self.url.lower() == "amazon.com":
|
|
893
|
-
return False
|
|
894
|
-
# Convert from amazon.com domain to native domain
|
|
895
|
-
if self.url.lower() != "amazon.com":
|
|
896
|
-
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}"
|
|
897
930
|
path = (
|
|
898
931
|
self._prefix
|
|
899
|
-
+
|
|
932
|
+
+ domain
|
|
900
933
|
+ f"/api/users/me?platform=ios&version={CALL_VERSION}"
|
|
901
934
|
)
|
|
935
|
+
|
|
902
936
|
self._log_cookies_for_url(path)
|
|
903
|
-
get_resp = await self._session.get(
|
|
937
|
+
get_resp = await self._session.get(
|
|
938
|
+
path,
|
|
939
|
+
cookies=cookies,
|
|
940
|
+
ssl=self._ssl,
|
|
941
|
+
)
|
|
904
942
|
await self._process_resp(get_resp)
|
|
943
|
+
|
|
905
944
|
try:
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
945
|
+
customer = await get_resp.json()
|
|
946
|
+
except (
|
|
947
|
+
JSONDecodeError,
|
|
948
|
+
SimpleJSONDecodeError,
|
|
949
|
+
ContentTypeError,
|
|
950
|
+
) as ex:
|
|
909
951
|
_LOGGER.debug(
|
|
910
|
-
"Not logged in: %s",
|
|
952
|
+
"Not logged in to %s: %s",
|
|
953
|
+
domain,
|
|
911
954
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
912
955
|
)
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
if "@" in self.email:
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
)
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
|
940
997
|
|
|
941
998
|
async def get_csrf_token(self) -> str | None:
|
|
942
999
|
"""Get an anti-CSRF token from an Amazon webpage."""
|
|
@@ -1013,11 +1070,44 @@ class AlexaLogin:
|
|
|
1013
1070
|
) -> None:
|
|
1014
1071
|
"""Login to Amazon."""
|
|
1015
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.
|
|
1016
1101
|
if cookies:
|
|
1017
|
-
_LOGGER.debug("Using cookies to log in")
|
|
1018
|
-
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
|
+
):
|
|
1019
1107
|
return
|
|
1108
|
+
_LOGGER.debug("Stored-cookie login failed; falling back to credentials")
|
|
1020
1109
|
await self.reset()
|
|
1110
|
+
|
|
1021
1111
|
_LOGGER.debug("Using credentials to log in")
|
|
1022
1112
|
if not self._site:
|
|
1023
1113
|
site: URL = self.start_url
|
|
@@ -1180,18 +1270,31 @@ class AlexaLogin:
|
|
|
1180
1270
|
assert isinstance(cookie_jar, aiohttp.CookieJar)
|
|
1181
1271
|
if self._debug:
|
|
1182
1272
|
_LOGGER.debug("Saving cookie to %s", cookiefile)
|
|
1273
|
+
temp_cookiefile = f"{self._cookiefile[0]}.{uuid4().hex}.tmp"
|
|
1183
1274
|
try:
|
|
1184
1275
|
serialized_cookie_jar = _serialize_cookie_jar(cookie_jar)
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
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
|
+
)
|
|
1189
1288
|
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
1190
1289
|
_LOGGER.debug(
|
|
1191
1290
|
"Error saving serialized cookie to %s: %s",
|
|
1192
1291
|
self._cookiefile[0].replace(self.email, hide_email(self.email)),
|
|
1193
1292
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
1194
1293
|
)
|
|
1294
|
+
finally:
|
|
1295
|
+
with contextlib.suppress(OSError):
|
|
1296
|
+
if os.path.exists(temp_cookiefile):
|
|
1297
|
+
await aioos.remove(temp_cookiefile)
|
|
1195
1298
|
elif (cookiefile) and os.path.exists(cookiefile):
|
|
1196
1299
|
_LOGGER.debug(
|
|
1197
1300
|
"Removing outdated cookiefile %s",
|
|
@@ -1219,11 +1322,6 @@ class AlexaLogin:
|
|
|
1219
1322
|
self._cookiefile[0].replace(self.email, hide_email(self.email)),
|
|
1220
1323
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
1221
1324
|
)
|
|
1222
|
-
if self._debug:
|
|
1223
|
-
_LOGGER.debug(
|
|
1224
|
-
"Deleted:\n%s",
|
|
1225
|
-
self._cookiefile.replace(self.email, hide_email(self.email))
|
|
1226
|
-
)
|
|
1227
1325
|
|
|
1228
1326
|
async def get_tokens(self) -> bool:
|
|
1229
1327
|
"""Get access and refresh tokens after registering device using cookies.
|
|
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
|