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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AlexaPy
3
- Version: 1.29.24
3
+ Version: 1.30.0
4
4
  Summary: Python API to control Amazon Echo Devices Programmatically.
5
5
  License: Apache-2.0
6
6
  License-File: AUTHORS.md
@@ -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
- AlexaAPI.devices[login.email] = (
1479
- devices if devices else AlexaAPI.devices[login.email]
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 = URL(api_base).with_path("/api/notifications").update_query(
2062
- {"cached": "true", "_": str(ts)}
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
- timeout=httpx.Timeout(None),
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, cookie: dict[str, Any]
217
+ cookie_jar: aiohttp.CookieJar,
218
+ cookie: dict[str, Any],
219
+ *,
220
+ fallback_domain: str = "",
207
221
  ) -> tuple[str, str] | None:
208
- """Restore one cookie entry to an aiohttp CookieJar."""
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, serialized: dict[str, Any]
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(cookie_jar, 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, cookies: dict[Any, Any]
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(cookie_jar, cookies)
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
- # Skip "partitioned" so older Python http.cookies does not fail.
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, cookies
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, cookies
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(self, cookies: dict[str, str] | None = None) -> bool:
852
- """Function that will test the connection is logged in.
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
- Tests:
855
- - Attempts to get authentication and compares to expected login email
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("Testing whether logged in to alexa.%s", self._url)
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
- await self.get_tokens()
867
- await self.register_capabilities()
868
- await self.exchange_token_for_cookies()
869
- await self.get_csrf()
870
- path = (
871
- self._prefix
872
- + "amazon.com"
873
- + f"/api/users/me?platform=ios&version={CALL_VERSION}"
874
- )
875
- self._log_cookies_for_url(path)
876
- get_resp = await self._session.get(
877
- path,
878
- cookies=cookies,
879
- ssl=self._ssl,
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
- + self._url
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(path)
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
- json = await get_resp.json()
907
- email = json.get("email")
908
- except (JSONDecodeError, SimpleJSONDecodeError, ContentTypeError) as ex:
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
- return False
914
- self.customer_id = json.get("id")
915
- if (email and email.lower() == self.email.lower()) or "@" not in self.email:
916
- if "@" in self.email:
917
- _LOGGER.debug(
918
- "Logged in as %s to %s with id: %s",
919
- hide_email(email),
920
- self.url,
921
- self.customer_id,
922
- )
923
- else:
924
- _LOGGER.debug(
925
- "Logged in as to %s mobile account %s with %s",
926
- hide_email(email),
927
- self.url,
928
- self.customer_id,
929
- )
930
- self.stats["login_timestamp"] = datetime.datetime.now()
931
- self.stats["api_calls"] = 0
932
- await self.check_domain()
933
- await self.finalize_login()
934
- return True
935
- _LOGGER.debug(
936
- "Not logged in due to email mismatch to stored %s", hide_email(email)
937
- )
938
- await self.reset()
939
- return False
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(cookies):
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
- async with aiofiles.open(
1186
- self._cookiefile[0], mode="w"
1187
- ) as localfile:
1188
- await localfile.write(dumps(serialized_cookie_jar))
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.
@@ -1,7 +1,7 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  [tool.poetry]
3
3
  name = "AlexaPy"
4
- version = "1.29.24"
4
+ version = "1.30.0"
5
5
  description = "Python API to control Amazon Echo Devices Programmatically."
6
6
  authors = [
7
7
  "Keaton Taylor <keatonstaylor@gmail.com>",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes