@camstack/addon-import-alexa 0.2.149 → 0.2.151
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 +9 -0
- package/assets/alexa-echo.LICENSE.md +13 -0
- package/assets/alexa-echo.svg +1 -0
- package/dist/{MotionZonesSettings-BD57OAIs.mjs → MotionZonesSettings-Dxo0EAzN.mjs} +2 -2
- package/dist/{PrivacyMaskSettings-ZxdPEelf.mjs → PrivacyMaskSettings-DwWxpZrj.mjs} +4 -4
- package/dist/{SceneMonitorEditor--KqtKX1B.mjs → SceneMonitorEditor-CC0dk5TR.mjs} +3 -3
- package/dist/_stub.js +250 -349
- package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CdRL7bFZ.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CcQjQJmj.mjs} +3 -3
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Msberbw_.mjs +26 -0
- package/dist/addon.js +1108 -598
- package/dist/addon.mjs +1110 -600
- package/dist/{hostInit-_k3o3MHN.mjs → hostInit-CRUwUtSm.mjs} +2 -2
- package/dist/{player-overlays-CJBC2I_E.mjs → player-overlays-DNpsEqAe.mjs} +1 -1
- package/dist/remoteEntry.js +1 -1
- package/dist/{responsive-N99niSJu.mjs → responsive-B82v3AbM.mjs} +1 -1
- package/dist/{scene-monitor-copy-DeuPVYmS.mjs → scene-monitor-copy-CPnjwzw6.mjs} +1 -1
- package/dist/{square-CnLcG0Zc.mjs → square-YTl_L1_3.mjs} +1 -1
- package/dist/{trash-2-BzimUQsl.mjs → trash-2-DCHugLVt.mjs} +1 -1
- package/dist/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-CsdMNhUS.mjs → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-DMdgQOgz.mjs} +1 -1
- package/i18n/en.json +7 -5
- package/i18n/it.json +7 -5
- package/package.json +1 -1
- package/python/alexa_gateway.py +85 -58
- package/python/alexa_protocol.py +22 -17
- package/python/test_alexa_gateway.py +141 -0
- package/python/test_alexa_protocol.py +44 -35
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-I8eNxvdp.mjs +0 -26
|
@@ -36,7 +36,7 @@ async function r() {
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"@camstack/types": {
|
|
39
|
-
version: "1.2.
|
|
39
|
+
version: "1.2.424",
|
|
40
40
|
scope: "default",
|
|
41
41
|
shareConfig: {
|
|
42
42
|
singleton: !0,
|
|
@@ -81,7 +81,7 @@ async function r() {
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"@camstack/ui-library": {
|
|
84
|
-
version: "1.2.
|
|
84
|
+
version: "1.2.335",
|
|
85
85
|
scope: "default",
|
|
86
86
|
shareConfig: {
|
|
87
87
|
singleton: !0,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { S as e, d as t, u as n, v as r } from "./_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react__loadShare__.js-DwXXYAlk.mjs";
|
|
2
2
|
import { n as i, r as a, t as o } from "./_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-CUiVYNMY.mjs";
|
|
3
|
-
import { h as s, m as c } from "./responsive-
|
|
3
|
+
import { h as s, m as c } from "./responsive-B82v3AbM.mjs";
|
|
4
4
|
var l = s("chevron-down", [["path", {
|
|
5
5
|
d: "m6 9 6 6 6-6",
|
|
6
6
|
key: "qrunsl"
|
package/dist/remoteEntry.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-
|
|
1
|
+
import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-DMdgQOgz.mjs";
|
|
2
2
|
export { t as get, e as init };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { S as e, d as t, g as n, i as r, l as i, n as a, r as o, u as s, v as c, y as l } from "./_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react__loadShare__.js-DwXXYAlk.mjs";
|
|
2
2
|
import "./_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-CUiVYNMY.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { c as u } from "./_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Msberbw_.mjs";
|
|
4
4
|
//#region ../../node_modules/lucide-react/dist/esm/shared/src/utils/mergeClasses.js
|
|
5
5
|
e();
|
|
6
6
|
var d = (...e) => e.filter((e, t, n) => !!e && e.trim() !== "" && n.indexOf(e) === t).join(" ").trim(), f = (e) => e.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), p = (e) => e.replace(/^([A-Z])|[\s-_]+(\w)/g, (e, t, n) => n ? n.toUpperCase() : t.toLowerCase()), m = (e) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { S as e, g as t, l as n, v as r } from "./_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react__loadShare__.js-DwXXYAlk.mjs";
|
|
2
2
|
import { n as i } from "./ui-text-B5YO4hoJ.mjs";
|
|
3
|
-
import { h as a } from "./responsive-
|
|
3
|
+
import { h as a } from "./responsive-B82v3AbM.mjs";
|
|
4
4
|
var o = a("camera", [["path", {
|
|
5
5
|
d: "M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z",
|
|
6
6
|
key: "18u6gg"
|
|
@@ -2753,7 +2753,7 @@ async function rr(e) {
|
|
|
2753
2753
|
}
|
|
2754
2754
|
}
|
|
2755
2755
|
async function ir() {
|
|
2756
|
-
return tr ||= rr(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-
|
|
2756
|
+
return tr ||= rr(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CcQjQJmj.mjs")).catch((e) => {
|
|
2757
2757
|
throw tr = void 0, e;
|
|
2758
2758
|
}), tr;
|
|
2759
2759
|
}
|
package/i18n/en.json
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
"connection.s_alexa-account.cookie.label": "Cookie blob (from proxy login)",
|
|
13
13
|
"connection.s_alexa-account.refreshToken.label": "Refresh token (alternative to cookie)",
|
|
14
14
|
"connection.s_alexa-account.pollIntervalMs.label": "State poll interval (ms)",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
15
|
+
"echoTarget.s_echo.title": "Echo",
|
|
16
|
+
"echoTarget.s_echo.brokerId.label": "Amazon account",
|
|
17
|
+
"echoTarget.s_echo.echoId.label": "Echo",
|
|
18
|
+
"echoTarget.s_echo.echoId.empty": "No Echo found yet. Sign the Amazon account in on Integrations, Alexa.",
|
|
19
|
+
"echoTarget.s_echo.mode.label": "Mode",
|
|
20
|
+
"echoTarget.s_echo.volume.label": "Volume",
|
|
21
|
+
"echoTarget.s_echo.respectDnd.label": "Respect Do Not Disturb"
|
|
20
22
|
}
|
package/i18n/it.json
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
"connection.s_alexa-account.cookie.label": "Blocco cookie (dal proxy di accesso)",
|
|
13
13
|
"connection.s_alexa-account.refreshToken.label": "Refresh token (alternativa al cookie)",
|
|
14
14
|
"connection.s_alexa-account.pollIntervalMs.label": "Intervallo di interrogazione dello stato (ms)",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
15
|
+
"echoTarget.s_echo.title": "Echo",
|
|
16
|
+
"echoTarget.s_echo.brokerId.label": "Account Amazon",
|
|
17
|
+
"echoTarget.s_echo.echoId.label": "Echo",
|
|
18
|
+
"echoTarget.s_echo.echoId.empty": "Nessun Echo trovato. Accedi con l'account Amazon da Integrazioni, Alexa.",
|
|
19
|
+
"echoTarget.s_echo.mode.label": "Modalità",
|
|
20
|
+
"echoTarget.s_echo.volume.label": "Volume",
|
|
21
|
+
"echoTarget.s_echo.respectDnd.label": "Rispetta Non disturbare"
|
|
20
22
|
}
|
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.151",
|
|
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",
|
package/python/alexa_gateway.py
CHANGED
|
@@ -1,34 +1,47 @@
|
|
|
1
1
|
"""alexapy adapter. Imported only when the worker starts, so unit tests can mock the gateway.
|
|
2
2
|
|
|
3
|
-
Never log the
|
|
3
|
+
Never log the refresh token, the cookie blob, or the session file contents.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
-
import asyncio
|
|
9
8
|
import time
|
|
10
9
|
from pathlib import Path
|
|
11
10
|
|
|
12
11
|
from alexa_sequences import EchoTarget
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
"NA": "https://alexa.amazon.com",
|
|
16
|
-
"EU": "https://alexa.amazon.co.uk",
|
|
17
|
-
"FE": "https://alexa.amazon.co.jp",
|
|
18
|
-
}
|
|
13
|
+
REGIONS = ("NA", "EU", "FE")
|
|
19
14
|
|
|
20
15
|
|
|
21
16
|
def classify_login(status: dict) -> str:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
17
|
+
"""The worker signs in with the broker's token, so a sign-in either works or it does not."""
|
|
18
|
+
return "ok" if status.get("login_successful") is True else "failed"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def bare_domain(domain: str) -> str:
|
|
22
|
+
"""alexapy wants the registrable Amazon domain: no scheme, no `www.` or `alexa.` host."""
|
|
23
|
+
text = domain.strip().lower()
|
|
24
|
+
for prefix in ("https://", "http://"):
|
|
25
|
+
if text.startswith(prefix):
|
|
26
|
+
text = text[len(prefix) :]
|
|
27
|
+
text = text.split("/", 1)[0]
|
|
28
|
+
for host in ("www.", "alexa."):
|
|
29
|
+
if text.startswith(host):
|
|
30
|
+
text = text[len(host) :]
|
|
31
|
+
return text or "amazon.com"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def session_output(session_path: str):
|
|
35
|
+
"""alexapy asks a callable where each of its files goes. Its primary cookie jar
|
|
36
|
+
is ours: the file the addon keeps (in the keychain) between runs."""
|
|
37
|
+
session = Path(session_path)
|
|
38
|
+
|
|
39
|
+
def output(name: str) -> str:
|
|
40
|
+
if name.startswith(".storage/") and name.endswith(".pickle"):
|
|
41
|
+
return str(session)
|
|
42
|
+
return str(session.parent / name.replace("/", "_"))
|
|
43
|
+
|
|
44
|
+
return output
|
|
32
45
|
|
|
33
46
|
|
|
34
47
|
def normalize_device(raw: dict) -> dict | None:
|
|
@@ -40,7 +53,8 @@ def normalize_device(raw: dict) -> dict | None:
|
|
|
40
53
|
online = bool(raw.get("online", True))
|
|
41
54
|
dnd = bool(raw.get("dnd") or raw.get("dndEnabled"))
|
|
42
55
|
kind = "group" if family.upper() == "WHA" else "device"
|
|
43
|
-
|
|
56
|
+
room = str(raw.get("roomName") or raw.get("room") or "")
|
|
57
|
+
result = {
|
|
44
58
|
"id": serial,
|
|
45
59
|
"name": name,
|
|
46
60
|
"family": family,
|
|
@@ -50,10 +64,13 @@ def normalize_device(raw: dict) -> dict | None:
|
|
|
50
64
|
"deviceType": str(raw.get("deviceType") or raw.get("deviceTypeId") or ""),
|
|
51
65
|
"customerId": str(raw.get("deviceOwnerCustomerId") or raw.get("customerId") or ""),
|
|
52
66
|
}
|
|
67
|
+
if room != "":
|
|
68
|
+
result["room"] = room
|
|
69
|
+
return result
|
|
53
70
|
|
|
54
71
|
|
|
55
72
|
class AlexaPyGateway:
|
|
56
|
-
"""Holds one AlexaLogin for the life of the process."""
|
|
73
|
+
"""Holds one AlexaLogin for the life of the process (one process per broker)."""
|
|
57
74
|
|
|
58
75
|
def __init__(self) -> None:
|
|
59
76
|
self._login = None
|
|
@@ -78,27 +95,39 @@ class AlexaPyGateway:
|
|
|
78
95
|
}
|
|
79
96
|
return None
|
|
80
97
|
|
|
81
|
-
async def
|
|
82
|
-
self
|
|
98
|
+
async def login_broker(
|
|
99
|
+
self,
|
|
100
|
+
label: str,
|
|
101
|
+
refresh_token: str,
|
|
102
|
+
device_app_name: str,
|
|
103
|
+
domain: str,
|
|
104
|
+
region: str,
|
|
105
|
+
session_path: str,
|
|
106
|
+
) -> dict:
|
|
107
|
+
"""Sign in as the account an import broker already signed in as.
|
|
108
|
+
|
|
109
|
+
The broker's refresh token is exchanged for this session's cookies; with
|
|
110
|
+
no token, the jar stored from an earlier run is tried. `label` is not an
|
|
111
|
+
e-mail address, so alexapy takes it as a mobile account and does not
|
|
112
|
+
compare it with the account's address.
|
|
113
|
+
"""
|
|
114
|
+
self._region = region if region in REGIONS else "EU"
|
|
83
115
|
self._session_path = session_path
|
|
84
|
-
url = REGION_URL[self._region]
|
|
85
116
|
AlexaLogin, _AlexaAPI = _import_alexapy()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
else:
|
|
101
|
-
await login.login(data={"otpCode": code})
|
|
117
|
+
oauth = {"refresh_token": refresh_token} if refresh_token != "" else {}
|
|
118
|
+
login = AlexaLogin(
|
|
119
|
+
bare_domain(domain), label, "", session_output(session_path), False, oauth=oauth
|
|
120
|
+
)
|
|
121
|
+
self._login = login
|
|
122
|
+
if refresh_token != "":
|
|
123
|
+
exchanged = await _exchange_refresh_token(login, device_app_name)
|
|
124
|
+
if not exchanged:
|
|
125
|
+
return {"status": "failed", "error": "Amazon did not accept the broker's token"}
|
|
126
|
+
await login.save_cookiefile()
|
|
127
|
+
cookies = await login.load_cookie()
|
|
128
|
+
if not cookies:
|
|
129
|
+
return {"status": "failed", "error": "no Amazon session to start from"}
|
|
130
|
+
await login.login(cookies=cookies)
|
|
102
131
|
await self._persist_session()
|
|
103
132
|
return self._status_result()
|
|
104
133
|
|
|
@@ -118,7 +147,11 @@ class AlexaPyGateway:
|
|
|
118
147
|
self._devices = devices
|
|
119
148
|
self._touch_success()
|
|
120
149
|
public = [
|
|
121
|
-
{
|
|
150
|
+
{
|
|
151
|
+
key: device[key]
|
|
152
|
+
for key in ("id", "name", "family", "online", "kind", "dnd", "deviceType", "room")
|
|
153
|
+
if key in device
|
|
154
|
+
}
|
|
122
155
|
for device in devices
|
|
123
156
|
]
|
|
124
157
|
return {"devices": public}
|
|
@@ -159,8 +192,7 @@ class AlexaPyGateway:
|
|
|
159
192
|
self._touch_success()
|
|
160
193
|
result: dict = {"status": kind}
|
|
161
194
|
if kind == "failed":
|
|
162
|
-
#
|
|
163
|
-
# the caller already scrubbed it before it reached us, and we do not attach it.
|
|
195
|
+
# Never attach the token: the library's message can echo the account, not our secret.
|
|
164
196
|
failed = str(status.get("login_failed") or status.get("error_message") or "login failed")
|
|
165
197
|
result["error"] = failed[:300]
|
|
166
198
|
return result
|
|
@@ -172,12 +204,7 @@ class AlexaPyGateway:
|
|
|
172
204
|
login = self._login
|
|
173
205
|
if login is None or self._session_path == "":
|
|
174
206
|
return
|
|
175
|
-
|
|
176
|
-
if not callable(save):
|
|
177
|
-
return
|
|
178
|
-
result = save(self._session_path)
|
|
179
|
-
if asyncio.iscoroutine(result):
|
|
180
|
-
await result
|
|
207
|
+
await login.save_cookiefile()
|
|
181
208
|
|
|
182
209
|
|
|
183
210
|
def _import_alexapy():
|
|
@@ -186,14 +213,14 @@ def _import_alexapy():
|
|
|
186
213
|
return AlexaLogin, AlexaAPI
|
|
187
214
|
|
|
188
215
|
|
|
189
|
-
async def
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
216
|
+
async def _exchange_refresh_token(login, device_app_name: str) -> bool:
|
|
217
|
+
"""Trade the broker's refresh token for cookies, under the name that token was registered with."""
|
|
218
|
+
import alexapy.alexalogin as module
|
|
219
|
+
|
|
220
|
+
previous = module.APP_NAME
|
|
221
|
+
if device_app_name != "":
|
|
222
|
+
module.APP_NAME = device_app_name
|
|
223
|
+
try:
|
|
224
|
+
return bool(await login.exchange_token_for_cookies())
|
|
225
|
+
finally:
|
|
226
|
+
module.APP_NAME = previous
|
package/python/alexa_protocol.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Line-delimited JSON session for the long-lived Echo worker.
|
|
2
2
|
|
|
3
3
|
One request object per stdin line, one response object per stdout line,
|
|
4
|
-
matched by ``id``. The
|
|
5
|
-
it is never taken from the JSON body.
|
|
4
|
+
matched by ``id``. The broker's refresh token is read from a file the request
|
|
5
|
+
names; it is never taken from the JSON body.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -18,12 +18,16 @@ REGION_LOCALE = {"NA": "en-US", "EU": "en-GB", "FE": "ja-JP"}
|
|
|
18
18
|
class Gateway(Protocol):
|
|
19
19
|
region: str
|
|
20
20
|
|
|
21
|
-
async def
|
|
22
|
-
self,
|
|
21
|
+
async def login_broker(
|
|
22
|
+
self,
|
|
23
|
+
label: str,
|
|
24
|
+
refresh_token: str,
|
|
25
|
+
device_app_name: str,
|
|
26
|
+
domain: str,
|
|
27
|
+
region: str,
|
|
28
|
+
session_path: str,
|
|
23
29
|
) -> dict: ...
|
|
24
30
|
|
|
25
|
-
async def login_otp(self, code: str, session_path: str) -> dict: ...
|
|
26
|
-
|
|
27
31
|
async def devices(self) -> dict: ...
|
|
28
32
|
|
|
29
33
|
async def speak(self, sequence: dict, customer_id: str) -> None: ...
|
|
@@ -35,8 +39,11 @@ class Gateway(Protocol):
|
|
|
35
39
|
def target_for(self, serial: str) -> EchoTarget | None: ...
|
|
36
40
|
|
|
37
41
|
|
|
38
|
-
def
|
|
39
|
-
|
|
42
|
+
def _read_secret(path: str) -> str:
|
|
43
|
+
"""A secret handed over as an owner-only file. An empty path means there is none."""
|
|
44
|
+
if path == "":
|
|
45
|
+
return ""
|
|
46
|
+
return Path(path).read_text(encoding="utf-8").strip()
|
|
40
47
|
|
|
41
48
|
|
|
42
49
|
class Session:
|
|
@@ -55,18 +62,16 @@ class Session:
|
|
|
55
62
|
return {"id": request_id, "ok": True, "result": result}
|
|
56
63
|
|
|
57
64
|
async def _dispatch(self, op: object, message: dict) -> dict:
|
|
58
|
-
if op == "
|
|
59
|
-
|
|
60
|
-
return await self._gateway.
|
|
61
|
-
str(message.get("
|
|
62
|
-
|
|
65
|
+
if op == "login_broker":
|
|
66
|
+
refresh_token = _read_secret(str(message.get("refreshTokenPath", "")))
|
|
67
|
+
return await self._gateway.login_broker(
|
|
68
|
+
str(message.get("label", "")),
|
|
69
|
+
refresh_token,
|
|
70
|
+
str(message.get("deviceAppName", "")),
|
|
71
|
+
str(message.get("domain", "")),
|
|
63
72
|
str(message.get("region", "EU")),
|
|
64
73
|
str(message.get("sessionPath", "")),
|
|
65
74
|
)
|
|
66
|
-
if op == "login_otp":
|
|
67
|
-
return await self._gateway.login_otp(
|
|
68
|
-
str(message.get("code", "")), str(message.get("sessionPath", ""))
|
|
69
|
-
)
|
|
70
75
|
if op == "devices":
|
|
71
76
|
return await self._gateway.devices()
|
|
72
77
|
if op == "speak":
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The gateway signs in as a broker's account, with alexapy replaced by a fake module.
|
|
2
|
+
|
|
3
|
+
These tests must not contact Amazon. They check what the gateway hands to alexapy
|
|
4
|
+
(the broker's token, the registered app name, where the cookie jar lives) and what
|
|
5
|
+
it does when Amazon refuses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import types
|
|
14
|
+
import unittest
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
18
|
+
|
|
19
|
+
from alexa_gateway import AlexaPyGateway, bare_domain, normalize_device, session_output
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def install_fake_alexapy(exchange_ok: bool = True, stored_cookies: dict | None = None):
|
|
23
|
+
"""Put a fake `alexapy` (and `alexapy.alexalogin`) in sys.modules; return the call log."""
|
|
24
|
+
calls: dict = {"exchange_app_names": [], "logins": [], "saved": 0}
|
|
25
|
+
module = types.ModuleType("alexapy.alexalogin")
|
|
26
|
+
module.APP_NAME = "Alexa Media Player"
|
|
27
|
+
|
|
28
|
+
class FakeLogin:
|
|
29
|
+
def __init__(self, url, email, password, outputpath, debug=False, oauth=None, **_):
|
|
30
|
+
calls["url"] = url
|
|
31
|
+
calls["email"] = email
|
|
32
|
+
calls["password"] = password
|
|
33
|
+
calls["oauth"] = dict(oauth or {})
|
|
34
|
+
calls["jar_path"] = outputpath(".storage/alexa_media.x.pickle")
|
|
35
|
+
self.status = {"login_successful": True}
|
|
36
|
+
self.refresh_token = (oauth or {}).get("refresh_token")
|
|
37
|
+
|
|
38
|
+
async def exchange_token_for_cookies(self):
|
|
39
|
+
calls["exchange_app_names"].append(module.APP_NAME)
|
|
40
|
+
calls["exchanged_token"] = self.refresh_token
|
|
41
|
+
return exchange_ok
|
|
42
|
+
|
|
43
|
+
async def save_cookiefile(self):
|
|
44
|
+
calls["saved"] += 1
|
|
45
|
+
Path(calls["jar_path"]).write_bytes(b"jar")
|
|
46
|
+
|
|
47
|
+
async def load_cookie(self):
|
|
48
|
+
return {"session-id": "1"} if (exchange_ok or stored_cookies) else {}
|
|
49
|
+
|
|
50
|
+
async def login(self, cookies=None, data=None):
|
|
51
|
+
calls["logins"].append(cookies)
|
|
52
|
+
|
|
53
|
+
package = types.ModuleType("alexapy")
|
|
54
|
+
package.AlexaLogin = FakeLogin
|
|
55
|
+
package.AlexaAPI = object
|
|
56
|
+
package.alexalogin = module
|
|
57
|
+
sys.modules["alexapy"] = package
|
|
58
|
+
sys.modules["alexapy.alexalogin"] = module
|
|
59
|
+
return calls, module
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def remove_fake_alexapy() -> None:
|
|
63
|
+
sys.modules.pop("alexapy", None)
|
|
64
|
+
sys.modules.pop("alexapy.alexalogin", None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run(coro):
|
|
68
|
+
return asyncio.run(coro)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GatewayTests(unittest.TestCase):
|
|
72
|
+
def tearDown(self) -> None:
|
|
73
|
+
remove_fake_alexapy()
|
|
74
|
+
|
|
75
|
+
def test_exchanges_the_brokers_token_under_its_registered_name_and_restores_the_default(self) -> None:
|
|
76
|
+
calls, module = install_fake_alexapy()
|
|
77
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
78
|
+
session_path = str(Path(directory) / "session")
|
|
79
|
+
result = run(
|
|
80
|
+
AlexaPyGateway().login_broker(
|
|
81
|
+
"alexa_001", "broker-refresh-token", "Registered App", "amazon.it", "EU", session_path
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
self.assertEqual(result, {"status": "ok"})
|
|
85
|
+
self.assertEqual(Path(session_path).read_bytes(), b"jar")
|
|
86
|
+
self.assertEqual(calls["exchanged_token"], "broker-refresh-token")
|
|
87
|
+
self.assertEqual(calls["exchange_app_names"], ["Registered App"])
|
|
88
|
+
self.assertEqual(module.APP_NAME, "Alexa Media Player")
|
|
89
|
+
self.assertEqual(calls["url"], "amazon.it")
|
|
90
|
+
self.assertEqual(calls["password"], "")
|
|
91
|
+
self.assertEqual(calls["email"], "alexa_001")
|
|
92
|
+
self.assertNotIn("@", calls["email"])
|
|
93
|
+
self.assertEqual(len(calls["logins"]), 1)
|
|
94
|
+
|
|
95
|
+
def test_an_account_two_brokers_share_is_still_two_labels(self) -> None:
|
|
96
|
+
first, _ = install_fake_alexapy()
|
|
97
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
98
|
+
run(AlexaPyGateway().login_broker("alexa_001", "t1", "", "amazon.it", "EU", str(Path(directory) / "s1")))
|
|
99
|
+
second, _ = install_fake_alexapy()
|
|
100
|
+
run(AlexaPyGateway().login_broker("alexa_002", "t2", "", "amazon.com", "NA", str(Path(directory) / "s2")))
|
|
101
|
+
self.assertEqual(first["email"], "alexa_001")
|
|
102
|
+
self.assertEqual(second["email"], "alexa_002")
|
|
103
|
+
self.assertEqual(second["exchanged_token"], "t2")
|
|
104
|
+
|
|
105
|
+
def test_a_token_amazon_refuses_is_a_named_failure_and_never_logs_in(self) -> None:
|
|
106
|
+
calls, module = install_fake_alexapy(exchange_ok=False)
|
|
107
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
108
|
+
result = run(
|
|
109
|
+
AlexaPyGateway().login_broker("alexa_001", "bad", "X", "amazon.com", "NA", str(Path(directory) / "s"))
|
|
110
|
+
)
|
|
111
|
+
self.assertEqual(result["status"], "failed")
|
|
112
|
+
self.assertNotIn("bad", result["error"])
|
|
113
|
+
self.assertEqual(calls["logins"], [])
|
|
114
|
+
self.assertEqual(module.APP_NAME, "Alexa Media Player")
|
|
115
|
+
|
|
116
|
+
def test_with_no_token_only_the_stored_jar_is_tried(self) -> None:
|
|
117
|
+
calls, _ = install_fake_alexapy(stored_cookies={"session-id": "1"})
|
|
118
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
119
|
+
result = run(
|
|
120
|
+
AlexaPyGateway().login_broker("alexa_001", "", "", "amazon.it", "EU", str(Path(directory) / "s"))
|
|
121
|
+
)
|
|
122
|
+
self.assertEqual(result, {"status": "ok"})
|
|
123
|
+
self.assertEqual(calls["exchange_app_names"], [])
|
|
124
|
+
|
|
125
|
+
def test_helpers(self) -> None:
|
|
126
|
+
self.assertEqual(bare_domain("https://alexa.amazon.co.uk/"), "amazon.co.uk")
|
|
127
|
+
self.assertEqual(bare_domain("www.amazon.de"), "amazon.de")
|
|
128
|
+
self.assertEqual(bare_domain(""), "amazon.com")
|
|
129
|
+
output = session_output("/tmp/x/session")
|
|
130
|
+
self.assertEqual(output(".storage/alexa_media.a.pickle"), "/tmp/x/session")
|
|
131
|
+
self.assertEqual(output("alexa_media.a.pickle"), "/tmp/x/alexa_media.a.pickle")
|
|
132
|
+
|
|
133
|
+
def test_a_room_is_passed_on_only_when_amazon_names_one(self) -> None:
|
|
134
|
+
with_room = normalize_device({"serialNumber": "s", "accountName": "Kitchen", "roomName": "Living room"})
|
|
135
|
+
without = normalize_device({"serialNumber": "s", "accountName": "Kitchen"})
|
|
136
|
+
self.assertEqual(with_room["room"], "Living room")
|
|
137
|
+
self.assertNotIn("room", without)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
unittest.main()
|