@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/CHANGELOG.md +12 -0
- package/dist/addon.js +539 -224
- package/dist/addon.mjs +539 -224
- package/package.json +2 -2
- package/python/alexa_gateway.py +34 -0
- package/python/alexa_protocol.py +4 -0
- package/python/test_alexa_gateway_echo.py +32 -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.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.
|
|
71
|
+
"server": ">=1.2.750"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"files": [
|
package/python/alexa_gateway.py
CHANGED
|
@@ -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):
|
package/python/alexa_protocol.py
CHANGED
|
@@ -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))
|