@camstack/addon-import-alexa 0.2.191 → 0.2.192

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.191",
3
+ "version": "0.2.192",
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",
@@ -72,7 +72,7 @@
72
72
  }
73
73
  ],
74
74
  "requires": {
75
- "server": ">=1.2.784"
75
+ "server": ">=1.2.786"
76
76
  }
77
77
  },
78
78
  "files": [
@@ -10,17 +10,23 @@ import time
10
10
  from pathlib import Path
11
11
  from types import SimpleNamespace
12
12
 
13
+ from alexa_push import Emit, PushChannel
13
14
  from alexa_sequences import EchoTarget, build_volume_node
14
15
 
15
16
  REGIONS = ("NA", "EU", "FE")
16
17
  REGION_LOCALE = {"NA": "en-US", "EU": "en-GB", "FE": "ja-JP"}
17
18
 
18
- # The `/api/np/command` bodies. Only these four are offered.
19
+ # The `/api/np/command` bodies. Amazon's shuffle and repeat are booleans sent as the
20
+ # strings "true" / "false"; there is no repeat-one.
19
21
  NP_COMMANDS = {
20
- "play": "PlayCommand",
21
- "pause": "PauseCommand",
22
- "next": "NextCommand",
23
- "previous": "PreviousCommand",
22
+ "play": {"type": "PlayCommand"},
23
+ "pause": {"type": "PauseCommand"},
24
+ "next": {"type": "NextCommand"},
25
+ "previous": {"type": "PreviousCommand"},
26
+ "shuffle-on": {"type": "ShuffleCommand", "shuffle": "true"},
27
+ "shuffle-off": {"type": "ShuffleCommand", "shuffle": "false"},
28
+ "repeat-on": {"type": "RepeatCommand", "repeat": "true"},
29
+ "repeat-off": {"type": "RepeatCommand", "repeat": "false"},
24
30
  }
25
31
  RATE_LIMITED = "rate-limited"
26
32
 
@@ -116,7 +122,9 @@ VOLUMES_PATH = "/api/devices/deviceType/dsn/audio/v1/allDeviceVolumes"
116
122
  class AlexaPyGateway:
117
123
  """Holds one AlexaLogin for the life of the process (one process per broker)."""
118
124
 
119
- def __init__(self) -> None:
125
+ def __init__(self, emit: Emit | None = None) -> None:
126
+ self._emit = emit
127
+ self._push: PushChannel | None = None
120
128
  self._login = None
121
129
  self._devices: list[dict] = []
122
130
  self._logged_in = False
@@ -206,7 +214,19 @@ class AlexaPyGateway:
206
214
  }
207
215
  await login.login(cookies=cookies)
208
216
  await self._persist_session()
209
- return self._status_result()
217
+ result = self._status_result()
218
+ if result["status"] == "ok":
219
+ self._start_push(login)
220
+ return result
221
+
222
+ def _start_push(self, login) -> None:
223
+ """Open the push channel on this session; a new sign-in replaces the old channel."""
224
+ if self._emit is None:
225
+ return
226
+ if self._push is not None:
227
+ self._push.stop()
228
+ self._push = PushChannel(login, self._emit)
229
+ self._push.start()
210
230
 
211
231
  async def devices(self) -> dict:
212
232
  login = self._require_login()
@@ -282,7 +302,7 @@ class AlexaPyGateway:
282
302
  device_type = self._device_type_of(serial)
283
303
  async with self._lock(serial):
284
304
  try:
285
- await run_command(self._api_for(serial, device_type).set_media({"type": body}), "the media command")
305
+ await run_command(self._api_for(serial, device_type).set_media(dict(body)), "the media command")
286
306
  except Exception as err: # noqa: BLE001 — typed below
287
307
  if is_rate_limited(err):
288
308
  raise RateLimitedError("amazon rate limit (429) on a media command") from err
@@ -0,0 +1,178 @@
1
+ """The Alexa push channel (alexapy's HTTP/2 client), forwarded as protocol lines.
2
+
3
+ One channel per signed-in account, opened with the session the gateway already
4
+ holds. Every event leaves as one unsolicited stdout line (no ``id``):
5
+
6
+ {"push": "channel", "state": "up" | "down", "reason": str, "retryInMs": int}
7
+ {"push": "event", "command": str, "serial": str | null}
8
+
9
+ Only the event name and the Echo's serial are forwarded: the addon re-reads the
10
+ Echo it names, so no payload (and nothing Amazon sent in it) crosses the pipe.
11
+ A channel that closes is reopened with a bounded, doubling delay; the addon keeps
12
+ polling as the fallback in the meantime.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import json
20
+ import time
21
+ from typing import Any, Awaitable, Callable
22
+
23
+ RECONNECT_BASE_S = 5
24
+ RECONNECT_MAX_S = 300
25
+ # A stream must stay up this long before the backoff starts over; one that opens and
26
+ # closes at once keeps doubling instead of flapping every few seconds.
27
+ STABLE_UP_S = 60
28
+
29
+ Emit = Callable[[dict], None]
30
+ ClientFactory = Callable[..., Any]
31
+ Sleep = Callable[[float], Awaitable[None]]
32
+
33
+
34
+ def _loads(text: object) -> object:
35
+ if not isinstance(text, str):
36
+ return None
37
+ try:
38
+ return json.loads(text)
39
+ except ValueError:
40
+ return None
41
+
42
+
43
+ def _serial_of(payload: dict) -> str | None:
44
+ doppler = payload.get("dopplerId")
45
+ if isinstance(doppler, dict) and isinstance(doppler.get("deviceSerialNumber"), str):
46
+ return doppler["deviceSerialNumber"]
47
+ key = payload.get("key")
48
+ if isinstance(key, dict) and isinstance(key.get("entryId"), str):
49
+ parts = key["entryId"].split("#")
50
+ if len(parts) > 2 and parts[2] != "":
51
+ return parts[2]
52
+ return None
53
+
54
+
55
+ def parse_push_message(message: object) -> list[dict]:
56
+ """The events of one HTTP/2 push message; a malformed item is skipped, never raised."""
57
+ if not isinstance(message, dict):
58
+ return []
59
+ directive = message.get("directive")
60
+ payload = directive.get("payload") if isinstance(directive, dict) else None
61
+ updates = payload.get("renderingUpdates") if isinstance(payload, dict) else None
62
+ if not isinstance(updates, list):
63
+ return []
64
+ events: list[dict] = []
65
+ for item in updates:
66
+ if not isinstance(item, dict):
67
+ continue
68
+ resource = _loads(item.get("resourceMetadata"))
69
+ if not isinstance(resource, dict):
70
+ continue
71
+ command = resource.get("command")
72
+ if not isinstance(command, str) or command == "":
73
+ continue
74
+ body = _loads(resource.get("payload"))
75
+ serial = _serial_of(body) if isinstance(body, dict) else None
76
+ events.append({"push": "event", "command": command, "serial": serial})
77
+ return events
78
+
79
+
80
+ def reconnect_delay_s(attempt: int) -> int:
81
+ return min(RECONNECT_BASE_S * 2 ** min(attempt, 10), RECONNECT_MAX_S)
82
+
83
+
84
+ def default_client_factory(login, msg_cb, open_cb, close_cb, error_cb):
85
+ from alexapy import HTTP2EchoClient
86
+
87
+ return HTTP2EchoClient(login, msg_cb, open_cb, close_cb, error_cb)
88
+
89
+
90
+ class PushChannel:
91
+ def __init__(
92
+ self,
93
+ login: object,
94
+ emit: Emit,
95
+ client_factory: ClientFactory | None = None,
96
+ sleep: Sleep = asyncio.sleep,
97
+ clock: Callable[[], float] = time.monotonic,
98
+ ) -> None:
99
+ self._login = login
100
+ self._emit = emit
101
+ self._factory = client_factory
102
+ self._sleep = sleep
103
+ self._clock = clock
104
+ self._stopped = False
105
+ self._up = False
106
+ self._task: asyncio.Task | None = None
107
+
108
+ def start(self) -> None:
109
+ if self._task is None:
110
+ self._task = asyncio.get_running_loop().create_task(self.run())
111
+
112
+ def stop(self) -> None:
113
+ self._stopped = True
114
+ if self._task is not None:
115
+ self._task.cancel()
116
+ self._task = None
117
+
118
+ def _down(self, reason: str, retry_s: int) -> None:
119
+ if not self._up:
120
+ return
121
+ self._up = False
122
+ self._emit(
123
+ {"push": "channel", "state": "down", "reason": reason[:200], "retryInMs": retry_s * 1000}
124
+ )
125
+
126
+ async def run(self) -> None:
127
+ attempt = 0
128
+ while not self._stopped:
129
+ closed = asyncio.Event()
130
+ reason = "closed"
131
+ opened_at: float | None = None
132
+
133
+ async def on_message(message: object) -> None:
134
+ for event in parse_push_message(message):
135
+ self._emit(event)
136
+
137
+ async def on_open() -> None:
138
+ nonlocal opened_at
139
+ opened_at = self._clock()
140
+ self._up = True
141
+ self._emit({"push": "channel", "state": "up", "reason": "connected", "retryInMs": 0})
142
+
143
+ async def on_close() -> None:
144
+ closed.set()
145
+
146
+ async def on_error(error: str) -> None:
147
+ nonlocal reason
148
+ reason = f"error: {error}"
149
+
150
+ client = None
151
+ try:
152
+ client = (self._factory or default_client_factory)(self._login, on_message, on_open, on_close, on_error)
153
+ await client.async_run()
154
+ if opened_at is None and not closed.is_set():
155
+ # Returned without opening or closing: that is a failed attempt, not a wait.
156
+ reason = "stream ended before opening"
157
+ else:
158
+ await closed.wait()
159
+ except asyncio.CancelledError:
160
+ raise
161
+ except Exception as err: # noqa: BLE001 — a failed connect is a retry, never a crash
162
+ reason = f"{type(err).__name__}: {err}"
163
+ finally:
164
+ if client is not None:
165
+ with contextlib.suppress(Exception):
166
+ client.on_close("channel ended")
167
+ if opened_at is not None and self._clock() - opened_at >= STABLE_UP_S:
168
+ attempt = 0
169
+ delay = reconnect_delay_s(attempt)
170
+ attempt += 1
171
+ if not self._up:
172
+ # Never opened: the attempt itself failed; tell the addon once per failed attempt.
173
+ self._emit(
174
+ {"push": "channel", "state": "down", "reason": reason[:200], "retryInMs": delay * 1000}
175
+ )
176
+ else:
177
+ self._down(reason, delay)
178
+ await self._sleep(delay)
@@ -14,12 +14,18 @@ import traceback
14
14
  from alexa_logging import configure_logging
15
15
 
16
16
 
17
+ def emit(line: dict) -> None:
18
+ """An unsolicited line (a push event). Same thread as the responses, so lines never interleave."""
19
+ sys.stdout.write(json.dumps(line) + "\n")
20
+ sys.stdout.flush()
21
+
22
+
17
23
  async def serve() -> None:
18
24
  # Imported here so `python -m unittest` of the protocol does not need alexapy.
19
25
  from alexa_gateway import AlexaPyGateway
20
26
  from alexa_protocol import Session
21
27
 
22
- session = Session(AlexaPyGateway())
28
+ session = Session(AlexaPyGateway(emit))
23
29
  loop = asyncio.get_running_loop()
24
30
  reader = asyncio.StreamReader()
25
31
  protocol = asyncio.StreamReaderProtocol(reader)
@@ -134,6 +134,20 @@ class EchoMediaTests(unittest.TestCase):
134
134
  with self.assertRaises(ValueError):
135
135
  run(gateway.np_command("s1", "rewind"))
136
136
 
137
+ def test_np_command_shuffle_and_repeat_post_the_boolean_string_bodies(self) -> None:
138
+ gateway, calls = install_fake()
139
+ for name in ("shuffle-on", "shuffle-off", "repeat-on", "repeat-off"):
140
+ run(gateway.np_command("s1", name))
141
+ self.assertEqual(
142
+ [data for _, data in calls["media"]],
143
+ [
144
+ {"type": "ShuffleCommand", "shuffle": "true"},
145
+ {"type": "ShuffleCommand", "shuffle": "false"},
146
+ {"type": "RepeatCommand", "repeat": "true"},
147
+ {"type": "RepeatCommand", "repeat": "false"},
148
+ ],
149
+ )
150
+
137
151
  def test_set_volume_posts_the_volume_behaviour_clamped(self) -> None:
138
152
  gateway, calls = install_fake()
139
153
  run(gateway.set_volume("s1", 140))
@@ -0,0 +1,227 @@
1
+ """The push channel parses alexapy's HTTP/2 messages and forwards them, with a fake client.
2
+
3
+ No network, no alexapy: the client the channel opens is a fake that the test drives.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+
17
+ import alexa_push
18
+ from alexa_push import PushChannel, parse_push_message, reconnect_delay_s
19
+ from test_alexa_gateway import install_fake_alexapy, remove_fake_alexapy
20
+
21
+
22
+ def message(command: str, payload: dict) -> dict:
23
+ resource = {"command": command, "payload": json.dumps(payload)}
24
+ return {"directive": {"payload": {"renderingUpdates": [{"resourceMetadata": json.dumps(resource)}]}}}
25
+
26
+
27
+ def volume_message(serial: str) -> dict:
28
+ return message("PUSH_VOLUME_CHANGE", {"dopplerId": {"deviceSerialNumber": serial}, "volumeSetting": 30})
29
+
30
+
31
+ class ParseTests(unittest.TestCase):
32
+ def test_reads_command_and_serial_from_the_doppler_id(self) -> None:
33
+ self.assertEqual(
34
+ parse_push_message(volume_message("S1")),
35
+ [{"push": "event", "command": "PUSH_VOLUME_CHANGE", "serial": "S1"}],
36
+ )
37
+
38
+ def test_reads_the_serial_from_a_key_entry_id(self) -> None:
39
+ events = parse_push_message(message("PUSH_NOTIFICATION_CHANGE", {"key": {"entryId": "a#b#SERIAL9"}}))
40
+ self.assertEqual(events[0]["serial"], "SERIAL9")
41
+
42
+ def test_an_event_without_a_serial_keeps_none_and_never_carries_the_payload(self) -> None:
43
+ events = parse_push_message(message("PUSH_EQUALIZER_STATE_CHANGE", {"bass": 1}))
44
+ self.assertEqual(events, [{"push": "event", "command": "PUSH_EQUALIZER_STATE_CHANGE", "serial": None}])
45
+
46
+ def test_garbage_is_skipped_not_raised(self) -> None:
47
+ for bad in (None, [], {}, {"directive": {"payload": {"renderingUpdates": [1, {"resourceMetadata": "{"}]}}}):
48
+ self.assertEqual(parse_push_message(bad), [])
49
+
50
+ def test_the_delay_doubles_and_is_bounded(self) -> None:
51
+ self.assertEqual([reconnect_delay_s(n) for n in (0, 1, 2)], [5, 10, 20])
52
+ self.assertEqual(reconnect_delay_s(50), alexa_push.RECONNECT_MAX_S)
53
+
54
+
55
+ class FakeClient:
56
+ """Stands in for HTTP2EchoClient; `script` decides what each connection does."""
57
+
58
+ instances: list["FakeClient"] = []
59
+
60
+ def __init__(self, login, msg_cb, open_cb, close_cb, error_cb) -> None:
61
+ self.msg_cb, self.open_cb, self.close_cb = msg_cb, open_cb, close_cb
62
+ self.closed_by_channel = False
63
+ FakeClient.instances.append(self)
64
+
65
+ async def async_run(self) -> None:
66
+ index = len(FakeClient.instances) - 1
67
+ if index == 0:
68
+ await self.open_cb()
69
+ await self.msg_cb(volume_message("S1"))
70
+ await self.close_cb()
71
+ elif index == 1:
72
+ raise RuntimeError("HTTP2 open timeout")
73
+ else:
74
+ await self.open_cb()
75
+ await self.msg_cb(volume_message("S2"))
76
+ await asyncio.sleep(3600)
77
+
78
+ def on_close(self, _reason=None) -> None:
79
+ self.closed_by_channel = True
80
+
81
+
82
+ class ChannelTests(unittest.TestCase):
83
+ def test_forwards_events_and_reconnects_with_a_bounded_delay(self) -> None:
84
+ FakeClient.instances = []
85
+ lines: list[dict] = []
86
+ sleeps: list[float] = []
87
+
88
+ async def fake_sleep(seconds: float) -> None:
89
+ sleeps.append(seconds)
90
+ await asyncio.sleep(0)
91
+
92
+ async def scenario() -> None:
93
+ channel = PushChannel(object(), lines.append, FakeClient, fake_sleep)
94
+ channel.start()
95
+ for _ in range(50):
96
+ await asyncio.sleep(0)
97
+ channel.stop()
98
+
99
+ asyncio.run(scenario())
100
+ kinds = [(line["push"], line.get("state") or line.get("command"), line.get("serial")) for line in lines]
101
+ self.assertEqual(
102
+ kinds,
103
+ [
104
+ ("channel", "up", None),
105
+ ("event", "PUSH_VOLUME_CHANGE", "S1"),
106
+ ("channel", "down", None), # first stream ended
107
+ ("channel", "down", None), # second attempt never opened
108
+ ("channel", "up", None), # third connected
109
+ ("event", "PUSH_VOLUME_CHANGE", "S2"),
110
+ ],
111
+ )
112
+ self.assertEqual(sleeps[:2], [5, 10])
113
+ self.assertTrue(all(client.closed_by_channel for client in FakeClient.instances[:2]))
114
+ self.assertEqual(lines[3]["reason"], "RuntimeError: HTTP2 open timeout")
115
+ self.assertEqual(lines[3]["retryInMs"], 10_000)
116
+
117
+
118
+ class FlapClient:
119
+ """Opens and closes at once, or returns without doing either."""
120
+
121
+ mode = "flap"
122
+ made = 0
123
+ hold = 0.0
124
+ now = [0.0]
125
+
126
+ def __init__(self, login, msg_cb, open_cb, close_cb, error_cb) -> None:
127
+ self.open_cb, self.close_cb = open_cb, close_cb
128
+ FlapClient.made += 1
129
+
130
+ async def async_run(self) -> None:
131
+ if FlapClient.mode == "flap":
132
+ await self.open_cb()
133
+ FlapClient.now[0] += FlapClient.hold # how long the stream stayed up
134
+ await self.close_cb()
135
+ # mode "silent": returns with neither open nor close
136
+
137
+ def on_close(self, _reason=None) -> None:
138
+ pass
139
+
140
+
141
+ class BackoffTests(unittest.TestCase):
142
+ def run_channel(self, mode: str, hold: float, passes: int = 40) -> tuple[list[dict], list[float]]:
143
+ FlapClient.mode, FlapClient.made, FlapClient.hold = mode, 0, hold
144
+ FlapClient.now = now = [0.0]
145
+ lines: list[dict] = []
146
+ sleeps: list[float] = []
147
+
148
+ async def fake_sleep(seconds: float) -> None:
149
+ sleeps.append(seconds)
150
+ await asyncio.sleep(0)
151
+
152
+ async def scenario() -> None:
153
+ channel = PushChannel(object(), lines.append, FlapClient, fake_sleep, lambda: now[0])
154
+ channel.start()
155
+ for _ in range(passes):
156
+ await asyncio.sleep(0)
157
+ channel.stop()
158
+
159
+ asyncio.run(scenario())
160
+ return lines, sleeps
161
+
162
+ def test_a_stream_that_opens_and_closes_at_once_keeps_backing_off(self) -> None:
163
+ _, sleeps = self.run_channel("flap", hold=0.0)
164
+ self.assertEqual(sleeps[:5], [5, 10, 20, 40, 80])
165
+
166
+ def test_the_backoff_starts_over_only_after_a_stable_stretch(self) -> None:
167
+ _, sleeps = self.run_channel("flap", hold=alexa_push.STABLE_UP_S + 1)
168
+ self.assertEqual(sleeps[:3], [5, 5, 5])
169
+
170
+ def test_a_client_that_returns_without_opening_is_retried_and_reported_down(self) -> None:
171
+ lines, sleeps = self.run_channel("silent", hold=0.0)
172
+ self.assertGreaterEqual(FlapClient.made, 2)
173
+ self.assertEqual(sleeps[:2], [5, 10])
174
+ self.assertEqual(lines[0]["state"], "down")
175
+ self.assertEqual(lines[0]["reason"], "stream ended before opening")
176
+
177
+
178
+ class GatewayPushTests(unittest.TestCase):
179
+ def tearDown(self) -> None:
180
+ remove_fake_alexapy()
181
+
182
+ def test_a_good_sign_in_opens_the_channel_with_the_same_login(self) -> None:
183
+ from alexa_gateway import AlexaPyGateway
184
+
185
+ install_fake_alexapy()
186
+ FakeClient.instances = []
187
+ seen: list[object] = []
188
+ lines: list[dict] = []
189
+ original = alexa_push.default_client_factory
190
+
191
+ def factory(login, *callbacks):
192
+ seen.append(login)
193
+ return FakeClient(login, *callbacks)
194
+
195
+ alexa_push.default_client_factory = factory
196
+
197
+ async def scenario() -> AlexaPyGateway:
198
+ gateway = AlexaPyGateway(lines.append)
199
+ with tempfile.TemporaryDirectory() as directory:
200
+ await gateway.login_broker("alexa_001", "tok", "App", "amazon.it", "EU", str(Path(directory) / "s"))
201
+ for _ in range(10):
202
+ await asyncio.sleep(0)
203
+ return gateway
204
+
205
+ try:
206
+ gateway = asyncio.run(scenario())
207
+ finally:
208
+ alexa_push.default_client_factory = original
209
+ self.assertIs(seen[0], gateway._login)
210
+ self.assertEqual(lines[0]["push"], "channel")
211
+
212
+ def test_no_emitter_means_no_channel(self) -> None:
213
+ from alexa_gateway import AlexaPyGateway
214
+
215
+ install_fake_alexapy()
216
+
217
+ async def scenario() -> AlexaPyGateway:
218
+ gateway = AlexaPyGateway()
219
+ with tempfile.TemporaryDirectory() as directory:
220
+ await gateway.login_broker("alexa_001", "tok", "App", "amazon.it", "EU", str(Path(directory) / "s"))
221
+ return gateway
222
+
223
+ self.assertIsNone(asyncio.run(scenario())._push)
224
+
225
+
226
+ if __name__ == "__main__":
227
+ unittest.main()