@camstack/addon-import-alexa 0.2.177 → 0.2.179
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 +12 -0
- package/dist/addon.js +1550 -315
- package/dist/addon.mjs +1550 -315
- package/package.json +2 -2
- package/python/alexa_gateway.py +123 -1
- package/python/alexa_protocol.py +43 -1
- package/python/alexa_sequences.py +15 -0
- package/python/test_alexa_gateway_echo.py +149 -0
- package/python/test_alexa_gateway_send.py +1 -1
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.179",
|
|
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",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
}
|
|
69
69
|
],
|
|
70
70
|
"requires": {
|
|
71
|
-
"server": ">=1.2.
|
|
71
|
+
"server": ">=1.2.742"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"files": [
|
package/python/alexa_gateway.py
CHANGED
|
@@ -5,12 +5,35 @@ Never log the refresh token, the cookie blob, or the session file contents.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import asyncio
|
|
8
9
|
import time
|
|
9
10
|
from pathlib import Path
|
|
11
|
+
from types import SimpleNamespace
|
|
10
12
|
|
|
11
|
-
from alexa_sequences import EchoTarget
|
|
13
|
+
from alexa_sequences import EchoTarget, build_volume_node
|
|
12
14
|
|
|
13
15
|
REGIONS = ("NA", "EU", "FE")
|
|
16
|
+
REGION_LOCALE = {"NA": "en-US", "EU": "en-GB", "FE": "ja-JP"}
|
|
17
|
+
|
|
18
|
+
# The `/api/np/command` bodies. Only these four are offered.
|
|
19
|
+
NP_COMMANDS = {
|
|
20
|
+
"play": "PlayCommand",
|
|
21
|
+
"pause": "PauseCommand",
|
|
22
|
+
"next": "NextCommand",
|
|
23
|
+
"previous": "PreviousCommand",
|
|
24
|
+
}
|
|
25
|
+
RATE_LIMITED = "rate-limited"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RateLimitedError(Exception):
|
|
29
|
+
"""Amazon answered 429. It is a refusal to retry later, never an empty answer."""
|
|
30
|
+
|
|
31
|
+
code = RATE_LIMITED
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_rate_limited(err: BaseException) -> bool:
|
|
35
|
+
"""alexapy raises AlexapyTooManyRequestsError; matched by name so this module needs no import of it."""
|
|
36
|
+
return any(kind.__name__ == "AlexapyTooManyRequestsError" for kind in type(err).__mro__)
|
|
14
37
|
|
|
15
38
|
|
|
16
39
|
def classify_login(status: dict) -> str:
|
|
@@ -83,6 +106,21 @@ class AlexaPyGateway:
|
|
|
83
106
|
self._last_success_at: float | None = None
|
|
84
107
|
self._session_path = ""
|
|
85
108
|
self._region = "EU"
|
|
109
|
+
self._serial_locks: dict[str, asyncio.Lock] = {}
|
|
110
|
+
|
|
111
|
+
def _lock(self, serial: str) -> asyncio.Lock:
|
|
112
|
+
"""One request at a time per Echo: Amazon throttles an account that hammers one device."""
|
|
113
|
+
lock = self._serial_locks.get(serial)
|
|
114
|
+
if lock is None:
|
|
115
|
+
lock = asyncio.Lock()
|
|
116
|
+
self._serial_locks[serial] = lock
|
|
117
|
+
return lock
|
|
118
|
+
|
|
119
|
+
def _api_for(self, serial: str, device_type: str):
|
|
120
|
+
login = self._require_login()
|
|
121
|
+
_AlexaLogin, AlexaAPI = _import_alexapy()
|
|
122
|
+
shim = SimpleNamespace(device_serial_number=serial, _device_type=device_type, _locale=None)
|
|
123
|
+
return AlexaAPI(shim, login)
|
|
86
124
|
|
|
87
125
|
@property
|
|
88
126
|
def region(self) -> str:
|
|
@@ -204,6 +242,90 @@ class AlexaPyGateway:
|
|
|
204
242
|
await AlexaAPI(None, login).run_behavior(node, queue_delay=0)
|
|
205
243
|
self._touch_success()
|
|
206
244
|
|
|
245
|
+
async def player_state(self, serial: str, device_type: str) -> dict:
|
|
246
|
+
"""The raw `playerInfo` of one Echo. A missing body is an error, not an idle player."""
|
|
247
|
+
async with self._lock(serial):
|
|
248
|
+
try:
|
|
249
|
+
body = await self._api_for(serial, device_type).get_state()
|
|
250
|
+
except Exception as err: # noqa: BLE001 — typed below
|
|
251
|
+
if is_rate_limited(err):
|
|
252
|
+
raise RateLimitedError("amazon rate limit (429) on the player state") from err
|
|
253
|
+
raise
|
|
254
|
+
if not isinstance(body, dict):
|
|
255
|
+
raise RuntimeError("amazon returned no player state")
|
|
256
|
+
info = body.get("playerInfo")
|
|
257
|
+
self._touch_success()
|
|
258
|
+
return {"playerInfo": info if isinstance(info, dict) else {}}
|
|
259
|
+
|
|
260
|
+
async def np_command(self, serial: str, command: str) -> None:
|
|
261
|
+
body = NP_COMMANDS.get(command)
|
|
262
|
+
if body is None:
|
|
263
|
+
raise ValueError(f"unknown media command {command!r}")
|
|
264
|
+
device_type = self._device_type_of(serial)
|
|
265
|
+
async with self._lock(serial):
|
|
266
|
+
try:
|
|
267
|
+
await self._api_for(serial, device_type).set_media({"type": body})
|
|
268
|
+
except Exception as err: # noqa: BLE001 — typed below
|
|
269
|
+
if is_rate_limited(err):
|
|
270
|
+
raise RateLimitedError("amazon rate limit (429) on a media command") from err
|
|
271
|
+
raise
|
|
272
|
+
self._touch_success()
|
|
273
|
+
|
|
274
|
+
async def set_volume(self, serial: str, level: int) -> None:
|
|
275
|
+
target = self.target_for(serial)
|
|
276
|
+
if target is None:
|
|
277
|
+
raise ValueError("unknown echo")
|
|
278
|
+
node = build_volume_node(target, max(0, min(100, int(level))), REGION_LOCALE.get(self._region, "en-GB"))
|
|
279
|
+
login = self._require_login()
|
|
280
|
+
_AlexaLogin, AlexaAPI = _import_alexapy()
|
|
281
|
+
async with self._lock(serial):
|
|
282
|
+
try:
|
|
283
|
+
await AlexaAPI(None, login).run_behavior(node, queue_delay=0)
|
|
284
|
+
except Exception as err: # noqa: BLE001 — typed below
|
|
285
|
+
if is_rate_limited(err):
|
|
286
|
+
raise RateLimitedError("amazon rate limit (429) on the volume") from err
|
|
287
|
+
raise
|
|
288
|
+
self._touch_success()
|
|
289
|
+
|
|
290
|
+
async def dnd_state(self) -> dict:
|
|
291
|
+
"""Do Not Disturb of every device, in one call."""
|
|
292
|
+
login = self._require_login()
|
|
293
|
+
_AlexaLogin, AlexaAPI = _import_alexapy()
|
|
294
|
+
try:
|
|
295
|
+
body = await AlexaAPI.get_dnd_state(login)
|
|
296
|
+
except Exception as err: # noqa: BLE001 — typed below
|
|
297
|
+
if is_rate_limited(err):
|
|
298
|
+
raise RateLimitedError("amazon rate limit (429) on Do Not Disturb") from err
|
|
299
|
+
raise
|
|
300
|
+
if not isinstance(body, dict):
|
|
301
|
+
raise RuntimeError("amazon returned no Do Not Disturb list")
|
|
302
|
+
rows = body.get("doNotDisturbDeviceStatusList")
|
|
303
|
+
devices = []
|
|
304
|
+
for row in rows if isinstance(rows, list) else []:
|
|
305
|
+
serial = row.get("deviceSerialNumber") if isinstance(row, dict) else None
|
|
306
|
+
enabled = row.get("enabled") if isinstance(row, dict) else None
|
|
307
|
+
if isinstance(serial, str) and serial != "" and isinstance(enabled, bool):
|
|
308
|
+
devices.append({"serial": serial, "enabled": enabled})
|
|
309
|
+
self._touch_success()
|
|
310
|
+
return {"devices": devices}
|
|
311
|
+
|
|
312
|
+
async def set_dnd(self, serial: str, enabled: bool) -> None:
|
|
313
|
+
device_type = self._device_type_of(serial)
|
|
314
|
+
async with self._lock(serial):
|
|
315
|
+
try:
|
|
316
|
+
await self._api_for(serial, device_type).set_dnd_state(enabled)
|
|
317
|
+
except Exception as err: # noqa: BLE001 — typed below
|
|
318
|
+
if is_rate_limited(err):
|
|
319
|
+
raise RateLimitedError("amazon rate limit (429) on Do Not Disturb") from err
|
|
320
|
+
raise
|
|
321
|
+
self._touch_success()
|
|
322
|
+
|
|
323
|
+
def _device_type_of(self, serial: str) -> str:
|
|
324
|
+
target = self.target_for(serial)
|
|
325
|
+
if target is None:
|
|
326
|
+
raise ValueError("unknown echo")
|
|
327
|
+
return target["deviceType"]
|
|
328
|
+
|
|
207
329
|
async def session_status(self) -> dict:
|
|
208
330
|
age = 0
|
|
209
331
|
if self._signed_in_at is not None:
|
package/python/alexa_protocol.py
CHANGED
|
@@ -36,6 +36,16 @@ class Gateway(Protocol):
|
|
|
36
36
|
|
|
37
37
|
async def session_status(self) -> dict: ...
|
|
38
38
|
|
|
39
|
+
async def player_state(self, serial: str, device_type: str) -> dict: ...
|
|
40
|
+
|
|
41
|
+
async def np_command(self, serial: str, command: str) -> None: ...
|
|
42
|
+
|
|
43
|
+
async def set_volume(self, serial: str, level: int) -> None: ...
|
|
44
|
+
|
|
45
|
+
async def dnd_state(self) -> dict: ...
|
|
46
|
+
|
|
47
|
+
async def set_dnd(self, serial: str, enabled: bool) -> None: ...
|
|
48
|
+
|
|
39
49
|
def target_for(self, serial: str) -> EchoTarget | None: ...
|
|
40
50
|
|
|
41
51
|
|
|
@@ -46,6 +56,13 @@ def _read_secret(path: str) -> str:
|
|
|
46
56
|
return Path(path).read_text(encoding="utf-8").strip()
|
|
47
57
|
|
|
48
58
|
|
|
59
|
+
def _serial(message: dict) -> str:
|
|
60
|
+
serial = message.get("serial")
|
|
61
|
+
if not isinstance(serial, str) or serial == "":
|
|
62
|
+
raise ValueError("serial required")
|
|
63
|
+
return serial
|
|
64
|
+
|
|
65
|
+
|
|
49
66
|
class Session:
|
|
50
67
|
def __init__(self, gateway: Gateway) -> None:
|
|
51
68
|
self._gateway = gateway
|
|
@@ -58,7 +75,11 @@ class Session:
|
|
|
58
75
|
try:
|
|
59
76
|
result = await self._dispatch(op, message)
|
|
60
77
|
except Exception as err: # noqa: BLE001 — the line must answer; the message is the error
|
|
61
|
-
|
|
78
|
+
failure: dict = {"id": request_id, "ok": False, "error": str(err)}
|
|
79
|
+
code = getattr(err, "code", None)
|
|
80
|
+
if isinstance(code, str):
|
|
81
|
+
failure["code"] = code
|
|
82
|
+
return failure
|
|
62
83
|
return {"id": request_id, "ok": True, "result": result}
|
|
63
84
|
|
|
64
85
|
async def _dispatch(self, op: object, message: dict) -> dict:
|
|
@@ -80,6 +101,27 @@ class Session:
|
|
|
80
101
|
return await self._utter("announce", message)
|
|
81
102
|
if op == "session_status":
|
|
82
103
|
return await self._gateway.session_status()
|
|
104
|
+
if op == "player_state":
|
|
105
|
+
return await self._gateway.player_state(
|
|
106
|
+
_serial(message), str(message.get("deviceType", ""))
|
|
107
|
+
)
|
|
108
|
+
if op == "np_command":
|
|
109
|
+
await self._gateway.np_command(_serial(message), str(message.get("command", "")))
|
|
110
|
+
return {}
|
|
111
|
+
if op == "set_volume":
|
|
112
|
+
level = message.get("level")
|
|
113
|
+
if isinstance(level, bool) or not isinstance(level, (int, float)):
|
|
114
|
+
raise ValueError("level required")
|
|
115
|
+
await self._gateway.set_volume(_serial(message), int(level))
|
|
116
|
+
return {}
|
|
117
|
+
if op == "dnd_state":
|
|
118
|
+
return await self._gateway.dnd_state()
|
|
119
|
+
if op == "set_dnd":
|
|
120
|
+
enabled = message.get("enabled")
|
|
121
|
+
if not isinstance(enabled, bool):
|
|
122
|
+
raise ValueError("enabled required")
|
|
123
|
+
await self._gateway.set_dnd(_serial(message), enabled)
|
|
124
|
+
return {}
|
|
83
125
|
raise ValueError(f"unknown op {op!r}")
|
|
84
126
|
|
|
85
127
|
async def _utter(self, op: str, message: dict) -> dict:
|
|
@@ -57,3 +57,18 @@ def build_announcement(target: EchoTarget, text: str, locale: str) -> dict:
|
|
|
57
57
|
],
|
|
58
58
|
},
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_volume_node(target: EchoTarget, level: int, locale: str) -> dict:
|
|
63
|
+
"""The behaviour node that sets an Echo's volume. `level` is a whole percent, 0 to 100."""
|
|
64
|
+
return {
|
|
65
|
+
"@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
|
|
66
|
+
"type": "Alexa.DeviceControls.Volume",
|
|
67
|
+
"operationPayload": {
|
|
68
|
+
"deviceType": target["deviceType"],
|
|
69
|
+
"deviceSerialNumber": target["id"],
|
|
70
|
+
"locale": locale,
|
|
71
|
+
"customerId": target["customerId"],
|
|
72
|
+
"value": level,
|
|
73
|
+
},
|
|
74
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Echo media ops with a mocked AlexaAPI. These tests must not contact Amazon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
import types
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
12
|
+
|
|
13
|
+
from alexa_gateway import AlexaPyGateway, RateLimitedError
|
|
14
|
+
from alexa_protocol import Session
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AlexapyTooManyRequestsError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(coro):
|
|
22
|
+
return asyncio.run(coro)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def install_fake(state=None, dnd=None, raises=None):
|
|
26
|
+
calls: dict = {"behaviors": [], "media": [], "dnd_set": [], "state_for": [], "active": 0, "max_active": 0}
|
|
27
|
+
|
|
28
|
+
class FakeAPI:
|
|
29
|
+
def __init__(self, device, login):
|
|
30
|
+
self.device = device
|
|
31
|
+
|
|
32
|
+
async def get_state(self):
|
|
33
|
+
calls["active"] += 1
|
|
34
|
+
calls["max_active"] = max(calls["max_active"], calls["active"])
|
|
35
|
+
await asyncio.sleep(0.01)
|
|
36
|
+
calls["active"] -= 1
|
|
37
|
+
calls["state_for"].append((self.device.device_serial_number, self.device._device_type))
|
|
38
|
+
if raises:
|
|
39
|
+
raise raises
|
|
40
|
+
return state
|
|
41
|
+
|
|
42
|
+
async def set_media(self, data):
|
|
43
|
+
if raises:
|
|
44
|
+
raise raises
|
|
45
|
+
calls["media"].append((self.device.device_serial_number, data))
|
|
46
|
+
|
|
47
|
+
async def set_dnd_state(self, enabled):
|
|
48
|
+
if raises:
|
|
49
|
+
raise raises
|
|
50
|
+
calls["dnd_set"].append((self.device.device_serial_number, enabled))
|
|
51
|
+
|
|
52
|
+
async def run_behavior(self, node, queue_delay=0):
|
|
53
|
+
if raises:
|
|
54
|
+
raise raises
|
|
55
|
+
calls["behaviors"].append(node)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
async def get_dnd_state(login):
|
|
59
|
+
if raises:
|
|
60
|
+
raise raises
|
|
61
|
+
return dnd
|
|
62
|
+
|
|
63
|
+
package = types.ModuleType("alexapy")
|
|
64
|
+
package.AlexaAPI = FakeAPI
|
|
65
|
+
package.AlexaLogin = object
|
|
66
|
+
sys.modules["alexapy"] = package
|
|
67
|
+
gateway = AlexaPyGateway()
|
|
68
|
+
gateway._login = object()
|
|
69
|
+
gateway._devices = [{"id": "s1", "deviceType": "T1", "customerId": "c1"}]
|
|
70
|
+
return gateway, calls
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class EchoMediaTests(unittest.TestCase):
|
|
74
|
+
def tearDown(self) -> None:
|
|
75
|
+
sys.modules.pop("alexapy", None)
|
|
76
|
+
|
|
77
|
+
def test_player_state_returns_the_raw_player_info(self) -> None:
|
|
78
|
+
gateway, calls = install_fake(state={"playerInfo": {"state": "PLAYING", "volume": {"volume": 30}}})
|
|
79
|
+
result = run(gateway.player_state("s1", "T1"))
|
|
80
|
+
self.assertEqual(result["playerInfo"]["state"], "PLAYING")
|
|
81
|
+
self.assertEqual(calls["state_for"], [("s1", "T1")])
|
|
82
|
+
|
|
83
|
+
def test_a_missing_body_is_an_error_never_an_idle_player(self) -> None:
|
|
84
|
+
gateway, _ = install_fake(state=None)
|
|
85
|
+
with self.assertRaises(RuntimeError):
|
|
86
|
+
run(gateway.player_state("s1", "T1"))
|
|
87
|
+
|
|
88
|
+
def test_a_429_is_a_typed_error_through_the_protocol(self) -> None:
|
|
89
|
+
gateway, _ = install_fake(raises=AlexapyTooManyRequestsError("Too Many Requests"))
|
|
90
|
+
response = run(Session(gateway).handle({"id": "1", "op": "player_state", "serial": "s1", "deviceType": "T1"}))
|
|
91
|
+
self.assertFalse(response["ok"])
|
|
92
|
+
self.assertEqual(response["code"], "rate-limited")
|
|
93
|
+
with self.assertRaises(RateLimitedError):
|
|
94
|
+
run(gateway.dnd_state())
|
|
95
|
+
|
|
96
|
+
def test_requests_for_one_serial_never_overlap(self) -> None:
|
|
97
|
+
gateway, calls = install_fake(state={"playerInfo": {}})
|
|
98
|
+
|
|
99
|
+
async def both():
|
|
100
|
+
await asyncio.gather(gateway.player_state("s1", "T1"), gateway.player_state("s1", "T1"))
|
|
101
|
+
|
|
102
|
+
run(both())
|
|
103
|
+
self.assertEqual(calls["max_active"], 1)
|
|
104
|
+
|
|
105
|
+
def test_np_command_maps_the_four_commands_and_refuses_others(self) -> None:
|
|
106
|
+
gateway, calls = install_fake()
|
|
107
|
+
for name in ("play", "pause", "next", "previous"):
|
|
108
|
+
run(gateway.np_command("s1", name))
|
|
109
|
+
self.assertEqual(
|
|
110
|
+
[data["type"] for _, data in calls["media"]],
|
|
111
|
+
["PlayCommand", "PauseCommand", "NextCommand", "PreviousCommand"],
|
|
112
|
+
)
|
|
113
|
+
with self.assertRaises(ValueError):
|
|
114
|
+
run(gateway.np_command("s1", "rewind"))
|
|
115
|
+
|
|
116
|
+
def test_set_volume_posts_the_volume_behaviour_clamped(self) -> None:
|
|
117
|
+
gateway, calls = install_fake()
|
|
118
|
+
run(gateway.set_volume("s1", 140))
|
|
119
|
+
node = calls["behaviors"][0]
|
|
120
|
+
self.assertEqual(node["type"], "Alexa.DeviceControls.Volume")
|
|
121
|
+
self.assertEqual(node["operationPayload"]["value"], 100)
|
|
122
|
+
self.assertEqual(node["operationPayload"]["deviceSerialNumber"], "s1")
|
|
123
|
+
|
|
124
|
+
def test_dnd_state_is_one_call_for_every_device(self) -> None:
|
|
125
|
+
gateway, _ = install_fake(
|
|
126
|
+
dnd={
|
|
127
|
+
"doNotDisturbDeviceStatusList": [
|
|
128
|
+
{"deviceSerialNumber": "s1", "enabled": True},
|
|
129
|
+
{"deviceSerialNumber": "s2", "enabled": False},
|
|
130
|
+
{"deviceSerialNumber": "s3"},
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
result = run(gateway.dnd_state())
|
|
135
|
+
self.assertEqual(result["devices"], [{"serial": "s1", "enabled": True}, {"serial": "s2", "enabled": False}])
|
|
136
|
+
|
|
137
|
+
def test_set_dnd_goes_to_the_device(self) -> None:
|
|
138
|
+
gateway, calls = install_fake()
|
|
139
|
+
run(gateway.set_dnd("s1", True))
|
|
140
|
+
self.assertEqual(calls["dnd_set"], [("s1", True)])
|
|
141
|
+
|
|
142
|
+
def test_an_unknown_echo_is_refused(self) -> None:
|
|
143
|
+
gateway, _ = install_fake()
|
|
144
|
+
with self.assertRaises(ValueError):
|
|
145
|
+
run(gateway.set_volume("nope", 10))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
unittest.main()
|
|
@@ -60,7 +60,7 @@ class SendTests(unittest.TestCase):
|
|
|
60
60
|
|
|
61
61
|
def test_speak_posts_the_sequence_start_node_immediately(self):
|
|
62
62
|
posted: list = []
|
|
63
|
-
sequence = build_speak_sequence(TARGET, "hello",
|
|
63
|
+
sequence = build_speak_sequence(TARGET, "hello", "it-IT")
|
|
64
64
|
asyncio.run(self._gateway(posted).speak(sequence, "A1CUST"))
|
|
65
65
|
self.assertEqual(posted, [(sequence["startNode"], 0)])
|
|
66
66
|
|