@camstack/addon-import-alexa 0.2.149 → 0.2.150

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/assets/alexa-echo.LICENSE.md +13 -0
  3. package/assets/alexa-echo.svg +1 -0
  4. package/dist/{MotionZonesSettings-BD57OAIs.mjs → MotionZonesSettings-Dxo0EAzN.mjs} +2 -2
  5. package/dist/{PrivacyMaskSettings-ZxdPEelf.mjs → PrivacyMaskSettings-DwWxpZrj.mjs} +4 -4
  6. package/dist/{SceneMonitorEditor--KqtKX1B.mjs → SceneMonitorEditor-CC0dk5TR.mjs} +3 -3
  7. package/dist/_stub.js +250 -349
  8. 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
  9. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Msberbw_.mjs +26 -0
  10. package/dist/addon.js +1106 -598
  11. package/dist/addon.mjs +1108 -600
  12. package/dist/{hostInit-_k3o3MHN.mjs → hostInit-CRUwUtSm.mjs} +2 -2
  13. package/dist/{player-overlays-CJBC2I_E.mjs → player-overlays-DNpsEqAe.mjs} +1 -1
  14. package/dist/remoteEntry.js +1 -1
  15. package/dist/{responsive-N99niSJu.mjs → responsive-B82v3AbM.mjs} +1 -1
  16. package/dist/{scene-monitor-copy-DeuPVYmS.mjs → scene-monitor-copy-CPnjwzw6.mjs} +1 -1
  17. package/dist/{square-CnLcG0Zc.mjs → square-YTl_L1_3.mjs} +1 -1
  18. package/dist/{trash-2-BzimUQsl.mjs → trash-2-DCHugLVt.mjs} +1 -1
  19. 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
  20. package/i18n/en.json +7 -5
  21. package/i18n/it.json +7 -5
  22. package/package.json +1 -1
  23. package/python/alexa_gateway.py +85 -58
  24. package/python/alexa_protocol.py +22 -17
  25. package/python/test_alexa_gateway.py +141 -0
  26. package/python/test_alexa_protocol.py +44 -35
  27. 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.422",
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.334",
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-N99niSJu.mjs";
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"
@@ -1,2 +1,2 @@
1
- import { n as e, t } from "./virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-CsdMNhUS.mjs";
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 { l as u } from "./_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-I8eNxvdp.mjs";
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-N99niSJu.mjs";
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"
@@ -1,4 +1,4 @@
1
- import { h as e } from "./responsive-N99niSJu.mjs";
1
+ import { h as e } from "./responsive-B82v3AbM.mjs";
2
2
  var t = e("eye-off", [
3
3
  ["path", {
4
4
  d: "M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49",
@@ -1,4 +1,4 @@
1
- import { h as e } from "./responsive-N99niSJu.mjs";
1
+ import { h as e } from "./responsive-B82v3AbM.mjs";
2
2
  var t = e("trash-2", [
3
3
  ["path", {
4
4
  d: "M10 11v6",
@@ -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-CdRL7bFZ.mjs")).catch((e) => {
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
- "globalSettings.s_alexa-speak.title": "Speak on Echo",
16
- "globalSettings.s_alexa-speak.description": "Sign in on the Speak on Echo page, pick the devices, and use them as a notification target. The text is the rule template, read aloud.",
17
- "globalSettings.s_alexa-speak.speak_email.label": "Amazon email",
18
- "globalSettings.s_alexa-speak.speak_password.label": "Amazon password",
19
- "globalSettings.s_alexa-speak.speak_region.label": "Region"
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
- "globalSettings.s_alexa-speak.title": "Parla sugli Echo",
16
- "globalSettings.s_alexa-speak.description": "Accedi dalla pagina Parla sugli Echo, scegli i dispositivi e usali come destinazione di una notifica. Il testo è il modello della regola, letto ad alta voce.",
17
- "globalSettings.s_alexa-speak.speak_email.label": "Email Amazon",
18
- "globalSettings.s_alexa-speak.speak_password.label": "Password Amazon",
19
- "globalSettings.s_alexa-speak.speak_region.label": "Regione"
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.149",
3
+ "version": "0.2.150",
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",
@@ -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 password, the cookie blob, or the session file contents.
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
- REGION_URL = {
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
- if status.get("login_successful") is True:
23
- return "ok"
24
- failed = str(status.get("login_failed") or status.get("error_message") or "").lower()
25
- if "captcha" in failed:
26
- return "captcha"
27
- if any(word in failed for word in ("otp", "2fa", "mfa", "verification code", "one-time")):
28
- return "otp_needed"
29
- if failed == "" and status.get("login_successful") is not True:
30
- return "failed"
31
- return "failed"
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
- return {
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 login_start(self, email: str, password: str, region: str, session_path: str) -> dict:
82
- self._region = region if region in REGION_URL else "EU"
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
- output = str(Path(session_path).parent)
87
- self._login = AlexaLogin(url, email, password, output, False)
88
- cookies = await _load_session(self._login, session_path)
89
- await self._login.login(cookies=cookies)
90
- await self._persist_session()
91
- return self._status_result()
92
-
93
- async def login_otp(self, code: str, session_path: str = "") -> dict:
94
- if session_path != "":
95
- self._session_path = session_path
96
- login = self._require_login()
97
- submit = getattr(login, "submit_otp", None)
98
- if callable(submit):
99
- await submit(code)
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
- {key: device[key] for key in ("id", "name", "family", "online", "kind", "dnd", "deviceType")}
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
- # The library's message can echo the account. Never include the password:
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
- save = getattr(login, "save_cookie", None)
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 _load_session(login, session_path: str):
190
- path = Path(session_path)
191
- if not path.is_file() or path.stat().st_size == 0:
192
- return None
193
- load = getattr(login, "load_cookie", None)
194
- if not callable(load):
195
- return None
196
- loaded = load(session_path)
197
- if asyncio.iscoroutine(loaded):
198
- return await loaded
199
- return loaded
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
@@ -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 password is read from a file the request names;
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 login_start(
22
- self, email: str, password: str, region: str, session_path: str
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 _read_password(path: str) -> str:
39
- return Path(path).read_text(encoding="utf-8")
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 == "login_start":
59
- password = _read_password(str(message.get("passwordPath", "")))
60
- return await self._gateway.login_start(
61
- str(message.get("email", "")),
62
- password,
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()