@camstack/addon-import-alexa 0.2.176 → 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/i18n/en.json CHANGED
@@ -14,7 +14,6 @@
14
14
  "echoTarget.s_echo.title": "Echo",
15
15
  "echoTarget.s_echo.brokerId.label": "Amazon account",
16
16
  "echoTarget.s_echo.echoId.label": "Echo",
17
- "echoTarget.s_echo.volume.label": "Volume",
18
17
  "echoTarget.s_echo.respectDnd.label": "Respect Do Not Disturb",
19
18
  "globalSettings": {
20
19
  "s_alexa-proxy-login": {
package/i18n/it.json CHANGED
@@ -14,7 +14,6 @@
14
14
  "echoTarget.s_echo.title": "Echo",
15
15
  "echoTarget.s_echo.brokerId.label": "Account Amazon",
16
16
  "echoTarget.s_echo.echoId.label": "Echo",
17
- "echoTarget.s_echo.volume.label": "Volume",
18
17
  "echoTarget.s_echo.respectDnd.label": "Rispetta Non disturbare",
19
18
  "globalSettings": {
20
19
  "s_alexa-proxy-login": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.176",
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.729"
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:
@@ -89,9 +131,6 @@ class Session:
89
131
  text = message.get("text")
90
132
  if not isinstance(text, str) or text == "":
91
133
  raise ValueError("text required")
92
- volume = message.get("volume")
93
- if not isinstance(volume, int):
94
- volume = 40
95
134
  locale = REGION_LOCALE.get(self._gateway.region, "en-GB")
96
135
  outcomes: list[dict] = []
97
136
  for serial in targets:
@@ -104,7 +143,7 @@ class Session:
104
143
  continue
105
144
  try:
106
145
  if op == "speak":
107
- sequence = build_speak_sequence(target, text, volume, locale)
146
+ sequence = build_speak_sequence(target, text, locale)
108
147
  await self._gateway.speak(sequence, target["customerId"])
109
148
  else:
110
149
  announcement = build_announcement(target, text, locale)
@@ -29,27 +29,12 @@ def _speak_node(target: EchoTarget, text: str, locale: str) -> dict:
29
29
  }
30
30
 
31
31
 
32
- def _volume_node(target: EchoTarget, volume: int, nxt: dict) -> dict:
33
- return {
34
- "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
35
- "type": "Alexa.DeviceControls.Volume",
36
- "operationPayload": {
37
- "deviceType": target["deviceType"],
38
- "deviceSerialNumber": target["id"],
39
- "customerId": target["customerId"],
40
- "value": volume,
41
- },
42
- "next": nxt,
43
- }
44
-
45
-
46
- def build_speak_sequence(target: EchoTarget, text: str, volume: int, locale: str) -> dict:
47
- """Volume, then speech. Speak honours Do Not Disturb on the device."""
32
+ def build_speak_sequence(target: EchoTarget, text: str, locale: str) -> dict:
33
+ """Speech at the Echo's current volume. Speak honours Do Not Disturb on the device."""
48
34
  speak = _speak_node(target, text, locale)
49
- start = _volume_node(target, volume, speak) if volume >= 0 else speak
50
35
  return {
51
36
  "@type": "com.amazon.alexa.behaviors.model.Sequence",
52
- "startNode": start,
37
+ "startNode": speak,
53
38
  }
54
39
 
55
40
 
@@ -72,3 +57,18 @@ def build_announcement(target: EchoTarget, text: str, locale: str) -> dict:
72
57
  ],
73
58
  },
74
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
 
@@ -136,19 +136,20 @@ class ProtocolTests(unittest.TestCase):
136
136
  session = Session(gateway)
137
137
  spoken = run(
138
138
  session,
139
- {"id": "4", "op": "speak", "targets": ["echo-1"], "text": "Person at the door", "volume": 30},
139
+ {"id": "4", "op": "speak", "targets": ["echo-1"], "text": "Person at the door"},
140
140
  )
141
141
  self.assertTrue(spoken["result"]["outcomes"][0]["ok"])
142
142
  sequence = gateway.spoken[0]["sequence"]
143
- self.assertEqual(sequence["startNode"]["type"], "Alexa.DeviceControls.Volume")
144
- self.assertEqual(sequence["startNode"]["next"]["type"], "Alexa.Speak")
143
+ # No volume node: the Echo speaks at its current volume.
144
+ self.assertEqual(sequence["startNode"]["type"], "Alexa.Speak")
145
+ self.assertNotIn("next", sequence["startNode"])
145
146
  self.assertEqual(
146
- sequence["startNode"]["next"]["operationPayload"]["textToSpeak"],
147
+ sequence["startNode"]["operationPayload"]["textToSpeak"],
147
148
  "Person at the door",
148
149
  )
149
150
  announced = run(
150
151
  session,
151
- {"id": "5", "op": "announce", "targets": ["echo-1"], "text": "Hello", "volume": 30},
152
+ {"id": "5", "op": "announce", "targets": ["echo-1"], "text": "Hello"},
152
153
  )
153
154
  self.assertTrue(announced["result"]["outcomes"][0]["ok"])
154
155
  self.assertEqual(gateway.announced[0]["content"][0]["speak"]["value"], "Hello")
@@ -167,7 +168,7 @@ class ProtocolTests(unittest.TestCase):
167
168
 
168
169
  def test_sequence_helpers_do_not_embed_markup(self) -> None:
169
170
  target = {"id": "echo-1", "deviceType": "T", "customerId": "c"}
170
- sequence = build_speak_sequence(target, "plain", 10, "en-GB")
171
+ sequence = build_speak_sequence(target, "plain", "en-GB")
171
172
  self.assertNotIn("<", json.dumps(sequence))
172
173
  announcement = build_announcement(target, "plain", "en-GB")
173
174
  self.assertEqual(announcement["content"][0]["speak"]["type"], "text")