@camstack/addon-import-alexa 0.2.179 → 0.2.181

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.179",
3
+ "version": "0.2.181",
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.742"
71
+ "server": ">=1.2.750"
72
72
  }
73
73
  },
74
74
  "files": [
@@ -95,6 +95,9 @@ def normalize_device(raw: dict) -> dict | None:
95
95
  # The skill alexapy itself names for an AlexaAnnouncement node.
96
96
  ANNOUNCEMENT_SKILL_ID = "amzn1.ask.1p.routines.messaging"
97
97
 
98
+ VOLUMES_PATH = "/api/devices/deviceType/dsn/audio/v1/allDeviceVolumes"
99
+
100
+
98
101
  class AlexaPyGateway:
99
102
  """Holds one AlexaLogin for the life of the process (one process per broker)."""
100
103
 
@@ -309,6 +312,37 @@ class AlexaPyGateway:
309
312
  self._touch_success()
310
313
  return {"devices": devices}
311
314
 
315
+ async def device_volumes(self) -> dict:
316
+ """Speaker volume and mute of every device, in one call (idle Echos included)."""
317
+ login = self._require_login()
318
+ _AlexaLogin, AlexaAPI = _import_alexapy()
319
+ try:
320
+ body = await AlexaAPI._static_request("get", login, VOLUMES_PATH)
321
+ except Exception as err: # noqa: BLE001 — typed below
322
+ if is_rate_limited(err):
323
+ raise RateLimitedError("amazon rate limit (429) on the device volumes") from err
324
+ raise
325
+ if not isinstance(body, dict):
326
+ raise RuntimeError("amazon returned no device volume list")
327
+ rows = body.get("volumes")
328
+ devices = []
329
+ for row in rows if isinstance(rows, list) else []:
330
+ if not isinstance(row, dict):
331
+ continue
332
+ serial = row.get("dsn")
333
+ volume = row.get("speakerVolume")
334
+ muted = row.get("speakerMuted")
335
+ if not isinstance(serial, str) or serial == "":
336
+ continue
337
+ entry = {"serial": serial}
338
+ if isinstance(volume, (int, float)) and not isinstance(volume, bool):
339
+ entry["volume"] = volume
340
+ if isinstance(muted, bool):
341
+ entry["muted"] = muted
342
+ devices.append(entry)
343
+ self._touch_success()
344
+ return {"devices": devices}
345
+
312
346
  async def set_dnd(self, serial: str, enabled: bool) -> None:
313
347
  device_type = self._device_type_of(serial)
314
348
  async with self._lock(serial):
@@ -44,6 +44,8 @@ class Gateway(Protocol):
44
44
 
45
45
  async def dnd_state(self) -> dict: ...
46
46
 
47
+ async def device_volumes(self) -> dict: ...
48
+
47
49
  async def set_dnd(self, serial: str, enabled: bool) -> None: ...
48
50
 
49
51
  def target_for(self, serial: str) -> EchoTarget | None: ...
@@ -116,6 +118,8 @@ class Session:
116
118
  return {}
117
119
  if op == "dnd_state":
118
120
  return await self._gateway.dnd_state()
121
+ if op == "device_volumes":
122
+ return await self._gateway.device_volumes()
119
123
  if op == "set_dnd":
120
124
  enabled = message.get("enabled")
121
125
  if not isinstance(enabled, bool):
@@ -22,7 +22,7 @@ def run(coro):
22
22
  return asyncio.run(coro)
23
23
 
24
24
 
25
- def install_fake(state=None, dnd=None, raises=None):
25
+ def install_fake(state=None, dnd=None, raises=None, volumes=None):
26
26
  calls: dict = {"behaviors": [], "media": [], "dnd_set": [], "state_for": [], "active": 0, "max_active": 0}
27
27
 
28
28
  class FakeAPI:
@@ -54,6 +54,13 @@ def install_fake(state=None, dnd=None, raises=None):
54
54
  raise raises
55
55
  calls["behaviors"].append(node)
56
56
 
57
+ @staticmethod
58
+ async def _static_request(method, login, uri, data=None, query=None):
59
+ if raises:
60
+ raise raises
61
+ calls.setdefault("static", []).append((method, uri))
62
+ return volumes
63
+
57
64
  @staticmethod
58
65
  async def get_dnd_state(login):
59
66
  if raises:
@@ -134,6 +141,30 @@ class EchoMediaTests(unittest.TestCase):
134
141
  result = run(gateway.dnd_state())
135
142
  self.assertEqual(result["devices"], [{"serial": "s1", "enabled": True}, {"serial": "s2", "enabled": False}])
136
143
 
144
+ def test_device_volumes_is_one_call_keeping_only_typed_readings(self) -> None:
145
+ gateway, calls = install_fake(
146
+ volumes={
147
+ "volumes": [
148
+ {"dsn": "s1", "deviceType": "T1", "speakerVolume": 46, "speakerMuted": False},
149
+ {"dsn": "s2", "speakerVolume": None, "speakerMuted": True},
150
+ {"deviceType": "T1", "speakerVolume": 10},
151
+ ]
152
+ }
153
+ )
154
+ result = run(gateway.device_volumes())
155
+ self.assertEqual(
156
+ result["devices"],
157
+ [{"serial": "s1", "volume": 46, "muted": False}, {"serial": "s2", "muted": True}],
158
+ )
159
+ self.assertEqual(
160
+ calls["static"], [("get", "/api/devices/deviceType/dsn/audio/v1/allDeviceVolumes")]
161
+ )
162
+
163
+ def test_device_volumes_429_is_typed(self) -> None:
164
+ gateway, _ = install_fake(raises=AlexapyTooManyRequestsError("Too Many Requests"))
165
+ with self.assertRaises(RateLimitedError):
166
+ run(gateway.device_volumes())
167
+
137
168
  def test_set_dnd_goes_to_the_device(self) -> None:
138
169
  gateway, calls = install_fake()
139
170
  run(gateway.set_dnd("s1", True))