@camstack/addon-import-alexa 0.2.177 → 0.2.178

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.177",
3
+ "version": "0.2.178",
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.730"
71
+ "server": ">=1.2.731"
72
72
  }
73
73
  },
74
74
  "files": [
@@ -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:
@@ -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
- return {"id": request_id, "ok": False, "error": str(err)}
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", 30, "it-IT")
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