@camstack/addon-import-alexa 0.2.148 → 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.
- package/CHANGELOG.md +9 -0
- package/assets/alexa-echo.LICENSE.md +13 -0
- package/assets/alexa-echo.svg +1 -0
- package/dist/MaskShapeCanvas-B6q5RDLi.mjs +13807 -0
- package/dist/MotionZonesSettings-Dxo0EAzN.mjs +309 -0
- package/dist/PrivacyMaskSettings-DwWxpZrj.mjs +368 -0
- package/dist/SceneMonitorEditor-CC0dk5TR.mjs +846 -0
- package/dist/_stub.js +39566 -0
- package/dist/_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CcQjQJmj.mjs +156 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.js-BmoaT3cr.mjs +25 -0
- 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/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-Bct5qZia.mjs +25 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_trpc_mf_1_client__loadShare__.js-BHqOw8oh.mjs +25 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.js-B3qxfxBT.mjs +26 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react__loadShare__.js-DwXXYAlk.mjs +70 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-CUiVYNMY.mjs +26 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom__loadShare__.js-CIPmAyPi.mjs +25 -0
- package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-tXm1x5EZ.mjs +25 -0
- package/dist/addon-import-alexa.css +3 -0
- package/dist/addon.js +2708 -149
- package/dist/addon.mjs +2724 -168
- package/dist/hostInit-CRUwUtSm.mjs +129 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +2 -2
- package/dist/player-overlays-DNpsEqAe.mjs +51 -0
- package/dist/remoteEntry.js +2 -0
- package/dist/remoteEntry.ssr.js +33 -0
- package/dist/responsive-B82v3AbM.mjs +125 -0
- package/dist/rolldown-runtime-HEgqtunE.mjs +20 -0
- package/dist/scene-monitor-copy-CPnjwzw6.mjs +227 -0
- package/dist/square-YTl_L1_3.mjs +45 -0
- package/dist/trash-2-DCHugLVt.mjs +25 -0
- package/dist/ui-text-B5YO4hoJ.mjs +34 -0
- package/dist/virtualExposes-lZT06jeg.mjs +2 -0
- package/dist/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-DMdgQOgz.mjs +2857 -0
- package/dist/virtual_mf-exposes-ssr___mfe_internal__addon_import_alexa_page__remoteEntry_js-CViESMdd.mjs +10 -0
- package/dist/virtual_mf-exposes___mfe_internal__addon_import_alexa_page__remoteEntry_js-C3qJCjtV.mjs +27 -0
- package/i18n/en.json +8 -1
- package/i18n/it.json +8 -1
- package/package.json +26 -4
- package/python/NOTICE +4 -0
- package/python/alexa_gateway.py +226 -0
- package/python/alexa_protocol.py +115 -0
- package/python/alexa_sequences.py +74 -0
- package/python/alexa_worker.py +50 -0
- package/python/requirements.txt +3 -0
- package/python/test_alexa_gateway.py +141 -0
- package/python/test_alexa_protocol.py +177 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region virtual:mf-exposes-ssr:__mfe_internal__addon_import_alexa_page__remoteEntry_js
|
|
2
|
+
var e = { "./page": async () => {
|
|
3
|
+
let e = await import("./_stub.js"), t = {};
|
|
4
|
+
return Object.assign(t, e), Object.defineProperty(t, "__esModule", {
|
|
5
|
+
value: !0,
|
|
6
|
+
enumerable: !1
|
|
7
|
+
}), t;
|
|
8
|
+
} };
|
|
9
|
+
//#endregion
|
|
10
|
+
export { e as default };
|
package/dist/virtual_mf-exposes___mfe_internal__addon_import_alexa_page__remoteEntry_js-C3qJCjtV.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region virtual:mf-exposes:__mfe_internal__addon_import_alexa_page__remoteEntry_js
|
|
2
|
+
var e = {}, t = /* @__PURE__ */ new Set(), n = Promise.resolve();
|
|
3
|
+
async function r(e) {
|
|
4
|
+
let t = n.then(e, e);
|
|
5
|
+
return n = t.then(() => void 0, () => void 0), t;
|
|
6
|
+
}
|
|
7
|
+
async function i(n) {
|
|
8
|
+
if (typeof document > "u") return;
|
|
9
|
+
let r = e[n] || [];
|
|
10
|
+
await Promise.all(r.map((e) => {
|
|
11
|
+
let n = new URL(e, import.meta.url).href;
|
|
12
|
+
return t.has(n) || (t.add(n), document.querySelector(`link[rel="stylesheet"][data-mf-href="${n}"]`)) ? Promise.resolve() : new Promise((e, t) => {
|
|
13
|
+
let r = document.createElement("link");
|
|
14
|
+
r.rel = "stylesheet", r.href = n, r.setAttribute("data-mf-href", n), r.onload = () => e(), r.onerror = () => t(/* @__PURE__ */ Error(`[Module Federation] Failed to load CSS asset: ${n}`)), document.head.appendChild(r);
|
|
15
|
+
});
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
var a = { "./page": async () => {
|
|
19
|
+
await i("./page");
|
|
20
|
+
let e = await r(() => import("./_stub.js")), t = {};
|
|
21
|
+
return Object.assign(t, e), Object.defineProperty(t, "__esModule", {
|
|
22
|
+
value: !0,
|
|
23
|
+
enumerable: !1
|
|
24
|
+
}), t;
|
|
25
|
+
} };
|
|
26
|
+
//#endregion
|
|
27
|
+
export { a as t };
|
package/i18n/en.json
CHANGED
|
@@ -11,5 +11,12 @@
|
|
|
11
11
|
"connection.s_alexa-account.amazonDomain.label": "Amazon domain (region)",
|
|
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
|
-
"connection.s_alexa-account.pollIntervalMs.label": "State poll interval (ms)"
|
|
14
|
+
"connection.s_alexa-account.pollIntervalMs.label": "State poll interval (ms)",
|
|
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"
|
|
15
22
|
}
|
package/i18n/it.json
CHANGED
|
@@ -11,5 +11,12 @@
|
|
|
11
11
|
"connection.s_alexa-account.amazonDomain.label": "Dominio Amazon (regione)",
|
|
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
|
-
"connection.s_alexa-account.pollIntervalMs.label": "Intervallo di interrogazione dello stato (ms)"
|
|
14
|
+
"connection.s_alexa-account.pollIntervalMs.label": "Intervallo di interrogazione dello stato (ms)",
|
|
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"
|
|
15
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.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",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"category": "providers",
|
|
39
39
|
"name": "Alexa Import",
|
|
40
40
|
"version": "0.1.0",
|
|
41
|
-
"description": "Import smart-home devices from an Amazon Alexa account (unofficial cookie/token client; poll-based state sync).
|
|
41
|
+
"description": "Import smart-home devices from an Amazon Alexa account (unofficial cookie/token client; poll-based state sync). The same account can speak a notification aloud on Echo devices.",
|
|
42
42
|
"entry": "./dist/addon.js",
|
|
43
43
|
"icon": "assets/icon.svg",
|
|
44
44
|
"color": "#00caff",
|
|
@@ -57,20 +57,42 @@
|
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"name": "addon-routes"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "notification-output"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "addon-pages-source"
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
"pages": [
|
|
69
|
+
{
|
|
70
|
+
"id": "speak",
|
|
71
|
+
"label": "Speak on Echo",
|
|
72
|
+
"icon": "megaphone",
|
|
73
|
+
"path": "/addon/import-alexa",
|
|
74
|
+
"remoteName": "addon_import_alexa_page",
|
|
75
|
+
"bundle": "remoteEntry.js",
|
|
76
|
+
"section": "administration"
|
|
60
77
|
}
|
|
61
|
-
]
|
|
78
|
+
],
|
|
79
|
+
"python": {
|
|
80
|
+
"requirements": "./python/requirements.txt"
|
|
81
|
+
}
|
|
62
82
|
}
|
|
63
83
|
]
|
|
64
84
|
},
|
|
65
85
|
"files": [
|
|
66
86
|
"dist",
|
|
87
|
+
"python",
|
|
67
88
|
"i18n",
|
|
68
89
|
"assets",
|
|
69
90
|
"README.md",
|
|
70
91
|
"CHANGELOG.md"
|
|
71
92
|
],
|
|
72
93
|
"scripts": {
|
|
73
|
-
"build": "vite build",
|
|
94
|
+
"build": "vite build && vite build -c vite.page.config.ts",
|
|
95
|
+
"build:page": "vite build -c vite.page.config.ts",
|
|
74
96
|
"dev": "vite build --watch",
|
|
75
97
|
"typecheck": "tsc --noEmit -p tsconfig.spec.json",
|
|
76
98
|
"test": "vitest run",
|
package/python/NOTICE
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""alexapy adapter. Imported only when the worker starts, so unit tests can mock the gateway.
|
|
2
|
+
|
|
3
|
+
Never log the refresh token, the cookie blob, or the session file contents.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from alexa_sequences import EchoTarget
|
|
12
|
+
|
|
13
|
+
REGIONS = ("NA", "EU", "FE")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def classify_login(status: dict) -> str:
|
|
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
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_device(raw: dict) -> dict | None:
|
|
48
|
+
serial = raw.get("serialNumber") or raw.get("deviceSerialNumber")
|
|
49
|
+
if not isinstance(serial, str) or serial == "":
|
|
50
|
+
return None
|
|
51
|
+
family = str(raw.get("deviceFamily") or "")
|
|
52
|
+
name = str(raw.get("accountName") or raw.get("deviceOwnerCustomerId") or serial)
|
|
53
|
+
online = bool(raw.get("online", True))
|
|
54
|
+
dnd = bool(raw.get("dnd") or raw.get("dndEnabled"))
|
|
55
|
+
kind = "group" if family.upper() == "WHA" else "device"
|
|
56
|
+
room = str(raw.get("roomName") or raw.get("room") or "")
|
|
57
|
+
result = {
|
|
58
|
+
"id": serial,
|
|
59
|
+
"name": name,
|
|
60
|
+
"family": family,
|
|
61
|
+
"online": online,
|
|
62
|
+
"kind": kind,
|
|
63
|
+
"dnd": dnd,
|
|
64
|
+
"deviceType": str(raw.get("deviceType") or raw.get("deviceTypeId") or ""),
|
|
65
|
+
"customerId": str(raw.get("deviceOwnerCustomerId") or raw.get("customerId") or ""),
|
|
66
|
+
}
|
|
67
|
+
if room != "":
|
|
68
|
+
result["room"] = room
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AlexaPyGateway:
|
|
73
|
+
"""Holds one AlexaLogin for the life of the process (one process per broker)."""
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self._login = None
|
|
77
|
+
self._devices: list[dict] = []
|
|
78
|
+
self._logged_in = False
|
|
79
|
+
self._signed_in_at: float | None = None
|
|
80
|
+
self._last_success_at: float | None = None
|
|
81
|
+
self._session_path = ""
|
|
82
|
+
self._region = "EU"
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def region(self) -> str:
|
|
86
|
+
return self._region
|
|
87
|
+
|
|
88
|
+
def target_for(self, serial: str) -> EchoTarget | None:
|
|
89
|
+
for device in self._devices:
|
|
90
|
+
if device["id"] == serial:
|
|
91
|
+
return {
|
|
92
|
+
"id": device["id"],
|
|
93
|
+
"deviceType": device["deviceType"],
|
|
94
|
+
"customerId": device["customerId"],
|
|
95
|
+
}
|
|
96
|
+
return None
|
|
97
|
+
|
|
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"
|
|
115
|
+
self._session_path = session_path
|
|
116
|
+
AlexaLogin, _AlexaAPI = _import_alexapy()
|
|
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)
|
|
131
|
+
await self._persist_session()
|
|
132
|
+
return self._status_result()
|
|
133
|
+
|
|
134
|
+
async def devices(self) -> dict:
|
|
135
|
+
login = self._require_login()
|
|
136
|
+
_AlexaLogin, AlexaAPI = _import_alexapy()
|
|
137
|
+
raw = await AlexaAPI.get_devices(login)
|
|
138
|
+
rows = raw.get("devices") if isinstance(raw, dict) else raw
|
|
139
|
+
if not isinstance(rows, list):
|
|
140
|
+
rows = []
|
|
141
|
+
devices = []
|
|
142
|
+
for row in rows:
|
|
143
|
+
if isinstance(row, dict):
|
|
144
|
+
normalized = normalize_device(row)
|
|
145
|
+
if normalized is not None:
|
|
146
|
+
devices.append(normalized)
|
|
147
|
+
self._devices = devices
|
|
148
|
+
self._touch_success()
|
|
149
|
+
public = [
|
|
150
|
+
{
|
|
151
|
+
key: device[key]
|
|
152
|
+
for key in ("id", "name", "family", "online", "kind", "dnd", "deviceType", "room")
|
|
153
|
+
if key in device
|
|
154
|
+
}
|
|
155
|
+
for device in devices
|
|
156
|
+
]
|
|
157
|
+
return {"devices": public}
|
|
158
|
+
|
|
159
|
+
async def speak(self, sequence: dict, customer_id: str) -> None:
|
|
160
|
+
login = self._require_login()
|
|
161
|
+
_AlexaLogin, AlexaAPI = _import_alexapy()
|
|
162
|
+
await AlexaAPI.send_sequence(login, sequence, customer_id, None)
|
|
163
|
+
self._touch_success()
|
|
164
|
+
|
|
165
|
+
async def announce(self, announcement: dict, customer_id: str) -> None:
|
|
166
|
+
login = self._require_login()
|
|
167
|
+
_AlexaLogin, AlexaAPI = _import_alexapy()
|
|
168
|
+
await AlexaAPI.send_announcement(login, announcement, customer_id, None)
|
|
169
|
+
self._touch_success()
|
|
170
|
+
|
|
171
|
+
async def session_status(self) -> dict:
|
|
172
|
+
age = 0
|
|
173
|
+
if self._signed_in_at is not None:
|
|
174
|
+
age = int(time.time() - self._signed_in_at)
|
|
175
|
+
last = int(self._last_success_at) if self._last_success_at is not None else None
|
|
176
|
+
return {"loggedIn": self._logged_in, "ageSec": age, "lastSuccessAt": last}
|
|
177
|
+
|
|
178
|
+
def _require_login(self):
|
|
179
|
+
if self._login is None:
|
|
180
|
+
raise RuntimeError("not signed in")
|
|
181
|
+
return self._login
|
|
182
|
+
|
|
183
|
+
def _status_result(self) -> dict:
|
|
184
|
+
login = self._require_login()
|
|
185
|
+
status = getattr(login, "status", {}) or {}
|
|
186
|
+
if not isinstance(status, dict):
|
|
187
|
+
status = {}
|
|
188
|
+
kind = classify_login(status)
|
|
189
|
+
self._logged_in = kind == "ok"
|
|
190
|
+
if self._logged_in and self._signed_in_at is None:
|
|
191
|
+
self._signed_in_at = time.time()
|
|
192
|
+
self._touch_success()
|
|
193
|
+
result: dict = {"status": kind}
|
|
194
|
+
if kind == "failed":
|
|
195
|
+
# Never attach the token: the library's message can echo the account, not our secret.
|
|
196
|
+
failed = str(status.get("login_failed") or status.get("error_message") or "login failed")
|
|
197
|
+
result["error"] = failed[:300]
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
def _touch_success(self) -> None:
|
|
201
|
+
self._last_success_at = time.time()
|
|
202
|
+
|
|
203
|
+
async def _persist_session(self) -> None:
|
|
204
|
+
login = self._login
|
|
205
|
+
if login is None or self._session_path == "":
|
|
206
|
+
return
|
|
207
|
+
await login.save_cookiefile()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _import_alexapy():
|
|
211
|
+
from alexapy import AlexaAPI, AlexaLogin
|
|
212
|
+
|
|
213
|
+
return AlexaLogin, AlexaAPI
|
|
214
|
+
|
|
215
|
+
|
|
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
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Line-delimited JSON session for the long-lived Echo worker.
|
|
2
|
+
|
|
3
|
+
One request object per stdin line, one response object per stdout line,
|
|
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
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from alexa_sequences import EchoTarget, build_announcement, build_speak_sequence
|
|
14
|
+
|
|
15
|
+
REGION_LOCALE = {"NA": "en-US", "EU": "en-GB", "FE": "ja-JP"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Gateway(Protocol):
|
|
19
|
+
region: str
|
|
20
|
+
|
|
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,
|
|
29
|
+
) -> dict: ...
|
|
30
|
+
|
|
31
|
+
async def devices(self) -> dict: ...
|
|
32
|
+
|
|
33
|
+
async def speak(self, sequence: dict, customer_id: str) -> None: ...
|
|
34
|
+
|
|
35
|
+
async def announce(self, announcement: dict, customer_id: str) -> None: ...
|
|
36
|
+
|
|
37
|
+
async def session_status(self) -> dict: ...
|
|
38
|
+
|
|
39
|
+
def target_for(self, serial: str) -> EchoTarget | None: ...
|
|
40
|
+
|
|
41
|
+
|
|
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()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Session:
|
|
50
|
+
def __init__(self, gateway: Gateway) -> None:
|
|
51
|
+
self._gateway = gateway
|
|
52
|
+
|
|
53
|
+
async def handle(self, message: dict) -> dict:
|
|
54
|
+
op = message.get("op")
|
|
55
|
+
request_id = message.get("id")
|
|
56
|
+
if not isinstance(request_id, str) or request_id == "":
|
|
57
|
+
return {"id": "", "ok": False, "error": "missing id"}
|
|
58
|
+
try:
|
|
59
|
+
result = await self._dispatch(op, message)
|
|
60
|
+
except Exception as err: # noqa: BLE001 — the line must answer; the message is the error
|
|
61
|
+
return {"id": request_id, "ok": False, "error": str(err)}
|
|
62
|
+
return {"id": request_id, "ok": True, "result": result}
|
|
63
|
+
|
|
64
|
+
async def _dispatch(self, op: object, message: dict) -> dict:
|
|
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", "")),
|
|
72
|
+
str(message.get("region", "EU")),
|
|
73
|
+
str(message.get("sessionPath", "")),
|
|
74
|
+
)
|
|
75
|
+
if op == "devices":
|
|
76
|
+
return await self._gateway.devices()
|
|
77
|
+
if op == "speak":
|
|
78
|
+
return await self._utter("speak", message)
|
|
79
|
+
if op == "announce":
|
|
80
|
+
return await self._utter("announce", message)
|
|
81
|
+
if op == "session_status":
|
|
82
|
+
return await self._gateway.session_status()
|
|
83
|
+
raise ValueError(f"unknown op {op!r}")
|
|
84
|
+
|
|
85
|
+
async def _utter(self, op: str, message: dict) -> dict:
|
|
86
|
+
targets = message.get("targets")
|
|
87
|
+
if not isinstance(targets, list) or not targets:
|
|
88
|
+
raise ValueError("targets required")
|
|
89
|
+
text = message.get("text")
|
|
90
|
+
if not isinstance(text, str) or text == "":
|
|
91
|
+
raise ValueError("text required")
|
|
92
|
+
volume = message.get("volume")
|
|
93
|
+
if not isinstance(volume, int):
|
|
94
|
+
volume = 40
|
|
95
|
+
locale = REGION_LOCALE.get(self._gateway.region, "en-GB")
|
|
96
|
+
outcomes: list[dict] = []
|
|
97
|
+
for serial in targets:
|
|
98
|
+
if not isinstance(serial, str):
|
|
99
|
+
outcomes.append({"targetId": "", "ok": False, "error": "bad target"})
|
|
100
|
+
continue
|
|
101
|
+
target = self._gateway.target_for(serial)
|
|
102
|
+
if target is None:
|
|
103
|
+
outcomes.append({"targetId": serial, "ok": False, "error": "unknown echo"})
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
if op == "speak":
|
|
107
|
+
sequence = build_speak_sequence(target, text, volume, locale)
|
|
108
|
+
await self._gateway.speak(sequence, target["customerId"])
|
|
109
|
+
else:
|
|
110
|
+
announcement = build_announcement(target, text, locale)
|
|
111
|
+
await self._gateway.announce(announcement, target["customerId"])
|
|
112
|
+
outcomes.append({"targetId": serial, "ok": True})
|
|
113
|
+
except Exception as err: # noqa: BLE001 — one Echo failing must not drop the others
|
|
114
|
+
outcomes.append({"targetId": serial, "ok": False, "error": str(err)})
|
|
115
|
+
return {"outcomes": outcomes}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Speak and announce payloads, the shape alexapy forwards to Amazon.
|
|
2
|
+
|
|
3
|
+
Built here so the protocol tests can see them without importing alexapy
|
|
4
|
+
and without contacting Amazon.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EchoTarget(TypedDict):
|
|
13
|
+
id: str
|
|
14
|
+
deviceType: str
|
|
15
|
+
customerId: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _speak_node(target: EchoTarget, text: str, locale: str) -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
|
|
21
|
+
"type": "Alexa.Speak",
|
|
22
|
+
"operationPayload": {
|
|
23
|
+
"deviceType": target["deviceType"],
|
|
24
|
+
"deviceSerialNumber": target["id"],
|
|
25
|
+
"locale": locale,
|
|
26
|
+
"customerId": target["customerId"],
|
|
27
|
+
"textToSpeak": text,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _volume_node(target: EchoTarget, volume: int, nxt: dict) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
|
|
35
|
+
"type": "Alexa.DeviceControls.Volume",
|
|
36
|
+
"operationPayload": {
|
|
37
|
+
"deviceType": target["deviceType"],
|
|
38
|
+
"deviceSerialNumber": target["id"],
|
|
39
|
+
"customerId": target["customerId"],
|
|
40
|
+
"value": volume,
|
|
41
|
+
},
|
|
42
|
+
"next": nxt,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_speak_sequence(target: EchoTarget, text: str, volume: int, locale: str) -> dict:
|
|
47
|
+
"""Volume, then speech. Speak honours Do Not Disturb on the device."""
|
|
48
|
+
speak = _speak_node(target, text, locale)
|
|
49
|
+
start = _volume_node(target, volume, speak) if volume >= 0 else speak
|
|
50
|
+
return {
|
|
51
|
+
"@type": "com.amazon.alexa.behaviors.model.Sequence",
|
|
52
|
+
"startNode": start,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_announcement(target: EchoTarget, text: str, locale: str) -> dict:
|
|
57
|
+
"""Chime plus speech. The caller skips a device that is in Do Not Disturb."""
|
|
58
|
+
return {
|
|
59
|
+
"customerId": target["customerId"],
|
|
60
|
+
"expireAfter": "PT5S",
|
|
61
|
+
"content": [
|
|
62
|
+
{
|
|
63
|
+
"locale": locale,
|
|
64
|
+
"display": {"title": "CamStack", "body": text},
|
|
65
|
+
"speak": {"type": "text", "value": text},
|
|
66
|
+
}
|
|
67
|
+
],
|
|
68
|
+
"target": {
|
|
69
|
+
"customerId": target["customerId"],
|
|
70
|
+
"devices": [
|
|
71
|
+
{"deviceSerialNumber": target["id"], "deviceTypeId": target["deviceType"]}
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Long-lived Echo worker. One process, line-delimited JSON on stdin/stdout.
|
|
2
|
+
|
|
3
|
+
Spawned once by the addon and restarted by it with a bound. Never one
|
|
4
|
+
process per message. Logs go to stderr so stdout stays a protocol.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
import traceback
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def serve() -> None:
|
|
16
|
+
# Imported here so `python -m unittest` of the protocol does not need alexapy.
|
|
17
|
+
from alexa_gateway import AlexaPyGateway
|
|
18
|
+
from alexa_protocol import Session
|
|
19
|
+
|
|
20
|
+
session = Session(AlexaPyGateway())
|
|
21
|
+
loop = asyncio.get_running_loop()
|
|
22
|
+
reader = asyncio.StreamReader()
|
|
23
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
24
|
+
await loop.connect_read_pipe(lambda: protocol, sys.stdin.buffer)
|
|
25
|
+
while True:
|
|
26
|
+
line = await reader.readline()
|
|
27
|
+
if not line:
|
|
28
|
+
return
|
|
29
|
+
text = line.decode("utf-8", errors="replace").strip()
|
|
30
|
+
if text == "":
|
|
31
|
+
continue
|
|
32
|
+
try:
|
|
33
|
+
message = json.loads(text)
|
|
34
|
+
if not isinstance(message, dict):
|
|
35
|
+
response = {"id": "", "ok": False, "error": "request must be an object"}
|
|
36
|
+
else:
|
|
37
|
+
response = await session.handle(message)
|
|
38
|
+
except Exception as err: # noqa: BLE001 — one bad line must not kill the process
|
|
39
|
+
traceback.print_exc(file=sys.stderr)
|
|
40
|
+
response = {"id": "", "ok": False, "error": str(err)}
|
|
41
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
42
|
+
sys.stdout.flush()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> None:
|
|
46
|
+
asyncio.run(serve())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|