@camstack/addon-import-alexa 0.2.188 → 0.2.190

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.
package/i18n/en.json CHANGED
@@ -1,7 +1,4 @@
1
1
  {
2
- "globalSettings.s_alexa-account.title": "Amazon Alexa account",
3
- "globalSettings.s_alexa-account.description": "Alexa accounts are managed in the External systems → Brokers tab.",
4
- "globalSettings.s_alexa-account.accountHelp.label": "Accounts are managed separately",
5
2
  "connection.s_alexa-account.title": "Amazon Alexa account",
6
3
  "connection.s_alexa-account.description": "Link an Amazon account via the unofficial Alexa client. Supply a captured cookie OR a refresh token. NOTE: this is an unofficial integration — Amazon can change or break it without notice, and it may violate Amazon ToS. State is polled, not pushed.",
7
4
  "connection.s_alexa-account.amazonDomain.label": "Amazon domain (region)",
package/i18n/it.json CHANGED
@@ -1,7 +1,4 @@
1
1
  {
2
- "globalSettings.s_alexa-account.title": "Account Amazon Alexa",
3
- "globalSettings.s_alexa-account.description": "Gli account Alexa si gestiscono nella scheda Sistemi esterni → Broker.",
4
- "globalSettings.s_alexa-account.accountHelp.label": "Gli account si gestiscono separatamente",
5
2
  "connection.s_alexa-account.title": "Account Amazon Alexa",
6
3
  "connection.s_alexa-account.description": "Collega un account Amazon tramite il client Alexa non ufficiale. Fornisci un cookie catturato OPPURE un refresh token. NOTA: è un'integrazione non ufficiale — Amazon può modificarla o interromperla senza preavviso, e potrebbe violare i termini di servizio Amazon. Lo stato viene interrogato, non inviato.",
7
4
  "connection.s_alexa-account.amazonDomain.label": "Dominio Amazon (regione)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.188",
3
+ "version": "0.2.190",
4
4
  "description": "Alexa device-import provider for CamStack — imports the smart-home devices in a user's Alexa account via the unofficial alexa-remote2 cookie/token client (the inverse of the Alexa exporter)",
5
5
  "keywords": [
6
6
  "camstack",
@@ -35,7 +35,7 @@
35
35
  "addons": [
36
36
  {
37
37
  "id": "import-alexa",
38
- "integrationRole": "both",
38
+ "integrationRole": "devices",
39
39
  "category": "providers",
40
40
  "name": "Alexa",
41
41
  "version": "0.1.0",
@@ -72,7 +72,7 @@
72
72
  }
73
73
  ],
74
74
  "requires": {
75
- "server": ">=1.2.774"
75
+ "server": ">=1.2.780"
76
76
  }
77
77
  },
78
78
  "files": [
@@ -24,6 +24,21 @@ NP_COMMANDS = {
24
24
  }
25
25
  RATE_LIMITED = "rate-limited"
26
26
 
27
+ # alexapy retries a 429 or a connection error by itself for 90-120 s per call and the
28
+ # decorator takes no per-call limit. A command is not worth that: cancelling the call
29
+ # from outside ends the retries, so the operator gets an answer while it still matters.
30
+ COMMAND_TIMEOUT_S = 10.0
31
+
32
+
33
+ async def run_command(call, what: str):
34
+ """Run one command coroutine, giving up after COMMAND_TIMEOUT_S (alexapy's retries are cancelled)."""
35
+ try:
36
+ return await asyncio.wait_for(call, timeout=COMMAND_TIMEOUT_S)
37
+ except asyncio.TimeoutError as err:
38
+ raise RuntimeError(
39
+ f"amazon did not accept {what} within {COMMAND_TIMEOUT_S:.0f} s (rate limit or connection retries)"
40
+ ) from err
41
+
27
42
 
28
43
  class RateLimitedError(Exception):
29
44
  """Amazon answered 429. It is a refusal to retry later, never an empty answer."""
@@ -267,7 +282,7 @@ class AlexaPyGateway:
267
282
  device_type = self._device_type_of(serial)
268
283
  async with self._lock(serial):
269
284
  try:
270
- await self._api_for(serial, device_type).set_media({"type": body})
285
+ await run_command(self._api_for(serial, device_type).set_media({"type": body}), "the media command")
271
286
  except Exception as err: # noqa: BLE001 — typed below
272
287
  if is_rate_limited(err):
273
288
  raise RateLimitedError("amazon rate limit (429) on a media command") from err
@@ -283,7 +298,7 @@ class AlexaPyGateway:
283
298
  _AlexaLogin, AlexaAPI = _import_alexapy()
284
299
  async with self._lock(serial):
285
300
  try:
286
- await AlexaAPI(None, login).run_behavior(node, queue_delay=0)
301
+ await run_command(AlexaAPI(None, login).run_behavior(node, queue_delay=0), "the volume")
287
302
  except Exception as err: # noqa: BLE001 — typed below
288
303
  if is_rate_limited(err):
289
304
  raise RateLimitedError("amazon rate limit (429) on the volume") from err
@@ -0,0 +1,33 @@
1
+ """Worker logging. stderr only (stdout is the protocol); the hub scrubs and forwards it.
2
+
3
+ alexapy retries a 429 or a connection error on its own and says so only through the
4
+ `backoff` library's INFO lines. Those are the one alexapy record let through: its other
5
+ records carry URLs and request arguments, so every other alexapy record stays out.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sys
12
+
13
+ # `backoff` formats these as "Backing off _request(...) for 1.2s (Error)": the call's
14
+ # arguments are elided, so a cookie or token cannot reach the line.
15
+ BACKOFF_PREFIXES = ("Backing off ", "Giving up ")
16
+
17
+
18
+ class AlexapyBackoffOnly(logging.Filter):
19
+ def filter(self, record: logging.LogRecord) -> bool:
20
+ if not record.name.startswith("alexapy"):
21
+ return record.levelno >= logging.WARNING
22
+ return record.getMessage().startswith(BACKOFF_PREFIXES)
23
+
24
+
25
+ def configure_logging(stream=None) -> logging.Handler:
26
+ handler = logging.StreamHandler(stream if stream is not None else sys.stderr)
27
+ handler.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
28
+ handler.addFilter(AlexapyBackoffOnly())
29
+ root = logging.getLogger()
30
+ root.addHandler(handler)
31
+ root.setLevel(logging.WARNING)
32
+ logging.getLogger("alexapy").setLevel(logging.INFO)
33
+ return handler
@@ -11,6 +11,8 @@ import json
11
11
  import sys
12
12
  import traceback
13
13
 
14
+ from alexa_logging import configure_logging
15
+
14
16
 
15
17
  async def serve() -> None:
16
18
  # Imported here so `python -m unittest` of the protocol does not need alexapy.
@@ -43,6 +45,7 @@ async def serve() -> None:
43
45
 
44
46
 
45
47
  def main() -> None:
48
+ configure_logging()
46
49
  asyncio.run(serve())
47
50
 
48
51
 
@@ -0,0 +1,80 @@
1
+ """A command fails fast instead of riding alexapy's 90-120 s retry; backoff lines reach stderr."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import io
7
+ import logging
8
+ import sys
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+
14
+ import alexa_gateway
15
+ from alexa_logging import configure_logging
16
+ from test_alexa_gateway_echo import install_fake
17
+
18
+
19
+ class CommandTimeoutTests(unittest.TestCase):
20
+ def setUp(self) -> None:
21
+ self._saved = alexa_gateway.COMMAND_TIMEOUT_S
22
+ alexa_gateway.COMMAND_TIMEOUT_S = 0.05
23
+
24
+ def tearDown(self) -> None:
25
+ alexa_gateway.COMMAND_TIMEOUT_S = self._saved
26
+ sys.modules.pop("alexapy", None)
27
+
28
+ def _stall(self, gateway, name: str) -> None:
29
+ async def forever(*_a, **_k):
30
+ await asyncio.sleep(30) # alexapy backing off a 429
31
+
32
+ import alexapy
33
+
34
+ setattr(alexapy.AlexaAPI, name, forever)
35
+
36
+ def test_set_volume_gives_up_instead_of_retrying_for_minutes(self) -> None:
37
+ gateway, _ = install_fake()
38
+ self._stall(gateway, "run_behavior")
39
+ with self.assertRaisesRegex(RuntimeError, "did not accept the volume"):
40
+ asyncio.run(gateway.set_volume("s1", 30))
41
+
42
+ def test_np_command_gives_up_too(self) -> None:
43
+ gateway, _ = install_fake()
44
+ self._stall(gateway, "set_media")
45
+ with self.assertRaisesRegex(RuntimeError, "did not accept the media command"):
46
+ asyncio.run(gateway.np_command("s1", "play"))
47
+
48
+ def test_a_stalled_command_releases_the_per_device_lock(self) -> None:
49
+ gateway, calls = install_fake()
50
+ self._stall(gateway, "run_behavior")
51
+
52
+ async def go():
53
+ with self.assertRaises(RuntimeError):
54
+ await gateway.set_volume("s1", 30)
55
+ await gateway.np_command("s1", "play")
56
+
57
+ asyncio.run(go())
58
+ self.assertEqual(len(calls["media"]), 1)
59
+
60
+
61
+ class LoggingTests(unittest.TestCase):
62
+ def test_backoff_lines_pass_and_other_alexapy_records_do_not(self) -> None:
63
+ stream = io.StringIO()
64
+ handler = configure_logging(stream)
65
+ try:
66
+ log = logging.getLogger("alexapy.alexaapi")
67
+ log.info("Backing off _request(...) for 1.2s (AlexapyTooManyRequestsError: 429)")
68
+ log.info("cookie=SECRET csrf=SECRET")
69
+ log.warning("HTTP (URL: https://x/?token=SECRET) error status 500")
70
+ logging.getLogger("camstack.worker").warning("our own warning")
71
+ finally:
72
+ logging.getLogger().removeHandler(handler)
73
+ out = stream.getvalue()
74
+ self.assertIn("Backing off _request(...)", out)
75
+ self.assertIn("our own warning", out)
76
+ self.assertNotIn("SECRET", out)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ unittest.main()