@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/CHANGELOG.md +16 -0
- package/dist/addon.js +1268 -903
- package/dist/addon.mjs +1268 -903
- package/i18n/en.json +0 -3
- package/i18n/it.json +0 -3
- package/package.json +3 -3
- package/python/alexa_gateway.py +17 -2
- package/python/alexa_logging.py +33 -0
- package/python/alexa_worker.py +3 -0
- package/python/test_alexa_command_limits.py +80 -0
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.
|
|
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": "
|
|
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.
|
|
75
|
+
"server": ">=1.2.780"
|
|
76
76
|
}
|
|
77
77
|
},
|
|
78
78
|
"files": [
|
package/python/alexa_gateway.py
CHANGED
|
@@ -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
|
package/python/alexa_worker.py
CHANGED
|
@@ -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()
|