@camstack/addon-import-alexa 0.2.191 → 0.2.193
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 +16 -0
- package/dist/addon.js +2893 -2020
- package/dist/addon.mjs +2894 -2021
- package/package.json +2 -2
- package/python/alexa_gateway.py +28 -8
- package/python/alexa_push.py +178 -0
- package/python/alexa_worker.py +7 -1
- package/python/test_alexa_gateway_echo.py +14 -0
- package/python/test_alexa_push.py +227 -0
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.193",
|
|
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.
|
|
75
|
+
"server": ">=1.2.787"
|
|
76
76
|
}
|
|
77
77
|
},
|
|
78
78
|
"files": [
|
package/python/alexa_gateway.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
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)
|
package/python/alexa_worker.py
CHANGED
|
@@ -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()
|