@camstack/addon-import-alexa 0.2.147 → 0.2.149
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 +12 -0
- package/README.md +18 -0
- package/dist/MaskShapeCanvas-B6q5RDLi.mjs +13807 -0
- package/dist/MotionZonesSettings-BD57OAIs.mjs +309 -0
- package/dist/PrivacyMaskSettings-ZxdPEelf.mjs +368 -0
- package/dist/SceneMonitorEditor--KqtKX1B.mjs +846 -0
- package/dist/_stub.js +39665 -0
- package/dist/_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CdRL7bFZ.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-I8eNxvdp.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 +3151 -650
- package/dist/addon.mjs +3167 -669
- package/dist/hostInit-_k3o3MHN.mjs +129 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +2 -2
- package/dist/player-overlays-CJBC2I_E.mjs +51 -0
- package/dist/remoteEntry.js +2 -0
- package/dist/remoteEntry.ssr.js +33 -0
- package/dist/responsive-N99niSJu.mjs +125 -0
- package/dist/rolldown-runtime-HEgqtunE.mjs +20 -0
- package/dist/scene-monitor-copy-DeuPVYmS.mjs +227 -0
- package/dist/square-CnLcG0Zc.mjs +45 -0
- package/dist/trash-2-BzimUQsl.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-CsdMNhUS.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 +6 -1
- package/i18n/it.json +6 -1
- package/package.json +29 -5
- package/python/NOTICE +4 -0
- package/python/alexa_gateway.py +199 -0
- package/python/alexa_protocol.py +110 -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_protocol.py +168 -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,10 @@
|
|
|
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
|
+
"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
20
|
}
|
package/i18n/it.json
CHANGED
|
@@ -11,5 +11,10 @@
|
|
|
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
|
+
"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
20
|
}
|
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.149",
|
|
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,18 +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
|
-
"assets"
|
|
89
|
+
"assets",
|
|
90
|
+
"README.md",
|
|
91
|
+
"CHANGELOG.md"
|
|
69
92
|
],
|
|
70
93
|
"scripts": {
|
|
71
|
-
"build": "vite build",
|
|
94
|
+
"build": "vite build && vite build -c vite.page.config.ts",
|
|
95
|
+
"build:page": "vite build -c vite.page.config.ts",
|
|
72
96
|
"dev": "vite build --watch",
|
|
73
97
|
"typecheck": "tsc --noEmit -p tsconfig.spec.json",
|
|
74
98
|
"test": "vitest run",
|
package/python/NOTICE
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""alexapy adapter. Imported only when the worker starts, so unit tests can mock the gateway.
|
|
2
|
+
|
|
3
|
+
Never log the password, the cookie blob, or the session file contents.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from alexa_sequences import EchoTarget
|
|
13
|
+
|
|
14
|
+
REGION_URL = {
|
|
15
|
+
"NA": "https://alexa.amazon.com",
|
|
16
|
+
"EU": "https://alexa.amazon.co.uk",
|
|
17
|
+
"FE": "https://alexa.amazon.co.jp",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
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"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_device(raw: dict) -> dict | None:
|
|
35
|
+
serial = raw.get("serialNumber") or raw.get("deviceSerialNumber")
|
|
36
|
+
if not isinstance(serial, str) or serial == "":
|
|
37
|
+
return None
|
|
38
|
+
family = str(raw.get("deviceFamily") or "")
|
|
39
|
+
name = str(raw.get("accountName") or raw.get("deviceOwnerCustomerId") or serial)
|
|
40
|
+
online = bool(raw.get("online", True))
|
|
41
|
+
dnd = bool(raw.get("dnd") or raw.get("dndEnabled"))
|
|
42
|
+
kind = "group" if family.upper() == "WHA" else "device"
|
|
43
|
+
return {
|
|
44
|
+
"id": serial,
|
|
45
|
+
"name": name,
|
|
46
|
+
"family": family,
|
|
47
|
+
"online": online,
|
|
48
|
+
"kind": kind,
|
|
49
|
+
"dnd": dnd,
|
|
50
|
+
"deviceType": str(raw.get("deviceType") or raw.get("deviceTypeId") or ""),
|
|
51
|
+
"customerId": str(raw.get("deviceOwnerCustomerId") or raw.get("customerId") or ""),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AlexaPyGateway:
|
|
56
|
+
"""Holds one AlexaLogin for the life of the process."""
|
|
57
|
+
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
self._login = None
|
|
60
|
+
self._devices: list[dict] = []
|
|
61
|
+
self._logged_in = False
|
|
62
|
+
self._signed_in_at: float | None = None
|
|
63
|
+
self._last_success_at: float | None = None
|
|
64
|
+
self._session_path = ""
|
|
65
|
+
self._region = "EU"
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def region(self) -> str:
|
|
69
|
+
return self._region
|
|
70
|
+
|
|
71
|
+
def target_for(self, serial: str) -> EchoTarget | None:
|
|
72
|
+
for device in self._devices:
|
|
73
|
+
if device["id"] == serial:
|
|
74
|
+
return {
|
|
75
|
+
"id": device["id"],
|
|
76
|
+
"deviceType": device["deviceType"],
|
|
77
|
+
"customerId": device["customerId"],
|
|
78
|
+
}
|
|
79
|
+
return None
|
|
80
|
+
|
|
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"
|
|
83
|
+
self._session_path = session_path
|
|
84
|
+
url = REGION_URL[self._region]
|
|
85
|
+
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})
|
|
102
|
+
await self._persist_session()
|
|
103
|
+
return self._status_result()
|
|
104
|
+
|
|
105
|
+
async def devices(self) -> dict:
|
|
106
|
+
login = self._require_login()
|
|
107
|
+
_AlexaLogin, AlexaAPI = _import_alexapy()
|
|
108
|
+
raw = await AlexaAPI.get_devices(login)
|
|
109
|
+
rows = raw.get("devices") if isinstance(raw, dict) else raw
|
|
110
|
+
if not isinstance(rows, list):
|
|
111
|
+
rows = []
|
|
112
|
+
devices = []
|
|
113
|
+
for row in rows:
|
|
114
|
+
if isinstance(row, dict):
|
|
115
|
+
normalized = normalize_device(row)
|
|
116
|
+
if normalized is not None:
|
|
117
|
+
devices.append(normalized)
|
|
118
|
+
self._devices = devices
|
|
119
|
+
self._touch_success()
|
|
120
|
+
public = [
|
|
121
|
+
{key: device[key] for key in ("id", "name", "family", "online", "kind", "dnd", "deviceType")}
|
|
122
|
+
for device in devices
|
|
123
|
+
]
|
|
124
|
+
return {"devices": public}
|
|
125
|
+
|
|
126
|
+
async def speak(self, sequence: dict, customer_id: str) -> None:
|
|
127
|
+
login = self._require_login()
|
|
128
|
+
_AlexaLogin, AlexaAPI = _import_alexapy()
|
|
129
|
+
await AlexaAPI.send_sequence(login, sequence, customer_id, None)
|
|
130
|
+
self._touch_success()
|
|
131
|
+
|
|
132
|
+
async def announce(self, announcement: dict, customer_id: str) -> None:
|
|
133
|
+
login = self._require_login()
|
|
134
|
+
_AlexaLogin, AlexaAPI = _import_alexapy()
|
|
135
|
+
await AlexaAPI.send_announcement(login, announcement, customer_id, None)
|
|
136
|
+
self._touch_success()
|
|
137
|
+
|
|
138
|
+
async def session_status(self) -> dict:
|
|
139
|
+
age = 0
|
|
140
|
+
if self._signed_in_at is not None:
|
|
141
|
+
age = int(time.time() - self._signed_in_at)
|
|
142
|
+
last = int(self._last_success_at) if self._last_success_at is not None else None
|
|
143
|
+
return {"loggedIn": self._logged_in, "ageSec": age, "lastSuccessAt": last}
|
|
144
|
+
|
|
145
|
+
def _require_login(self):
|
|
146
|
+
if self._login is None:
|
|
147
|
+
raise RuntimeError("not signed in")
|
|
148
|
+
return self._login
|
|
149
|
+
|
|
150
|
+
def _status_result(self) -> dict:
|
|
151
|
+
login = self._require_login()
|
|
152
|
+
status = getattr(login, "status", {}) or {}
|
|
153
|
+
if not isinstance(status, dict):
|
|
154
|
+
status = {}
|
|
155
|
+
kind = classify_login(status)
|
|
156
|
+
self._logged_in = kind == "ok"
|
|
157
|
+
if self._logged_in and self._signed_in_at is None:
|
|
158
|
+
self._signed_in_at = time.time()
|
|
159
|
+
self._touch_success()
|
|
160
|
+
result: dict = {"status": kind}
|
|
161
|
+
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.
|
|
164
|
+
failed = str(status.get("login_failed") or status.get("error_message") or "login failed")
|
|
165
|
+
result["error"] = failed[:300]
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
def _touch_success(self) -> None:
|
|
169
|
+
self._last_success_at = time.time()
|
|
170
|
+
|
|
171
|
+
async def _persist_session(self) -> None:
|
|
172
|
+
login = self._login
|
|
173
|
+
if login is None or self._session_path == "":
|
|
174
|
+
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
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _import_alexapy():
|
|
184
|
+
from alexapy import AlexaAPI, AlexaLogin
|
|
185
|
+
|
|
186
|
+
return AlexaLogin, AlexaAPI
|
|
187
|
+
|
|
188
|
+
|
|
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
|
|
@@ -0,0 +1,110 @@
|
|
|
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 password is read from a file the request names;
|
|
5
|
+
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_start(
|
|
22
|
+
self, email: str, password: str, region: str, session_path: str
|
|
23
|
+
) -> dict: ...
|
|
24
|
+
|
|
25
|
+
async def login_otp(self, code: str, session_path: str) -> dict: ...
|
|
26
|
+
|
|
27
|
+
async def devices(self) -> dict: ...
|
|
28
|
+
|
|
29
|
+
async def speak(self, sequence: dict, customer_id: str) -> None: ...
|
|
30
|
+
|
|
31
|
+
async def announce(self, announcement: dict, customer_id: str) -> None: ...
|
|
32
|
+
|
|
33
|
+
async def session_status(self) -> dict: ...
|
|
34
|
+
|
|
35
|
+
def target_for(self, serial: str) -> EchoTarget | None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_password(path: str) -> str:
|
|
39
|
+
return Path(path).read_text(encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Session:
|
|
43
|
+
def __init__(self, gateway: Gateway) -> None:
|
|
44
|
+
self._gateway = gateway
|
|
45
|
+
|
|
46
|
+
async def handle(self, message: dict) -> dict:
|
|
47
|
+
op = message.get("op")
|
|
48
|
+
request_id = message.get("id")
|
|
49
|
+
if not isinstance(request_id, str) or request_id == "":
|
|
50
|
+
return {"id": "", "ok": False, "error": "missing id"}
|
|
51
|
+
try:
|
|
52
|
+
result = await self._dispatch(op, message)
|
|
53
|
+
except Exception as err: # noqa: BLE001 — the line must answer; the message is the error
|
|
54
|
+
return {"id": request_id, "ok": False, "error": str(err)}
|
|
55
|
+
return {"id": request_id, "ok": True, "result": result}
|
|
56
|
+
|
|
57
|
+
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,
|
|
63
|
+
str(message.get("region", "EU")),
|
|
64
|
+
str(message.get("sessionPath", "")),
|
|
65
|
+
)
|
|
66
|
+
if op == "login_otp":
|
|
67
|
+
return await self._gateway.login_otp(
|
|
68
|
+
str(message.get("code", "")), str(message.get("sessionPath", ""))
|
|
69
|
+
)
|
|
70
|
+
if op == "devices":
|
|
71
|
+
return await self._gateway.devices()
|
|
72
|
+
if op == "speak":
|
|
73
|
+
return await self._utter("speak", message)
|
|
74
|
+
if op == "announce":
|
|
75
|
+
return await self._utter("announce", message)
|
|
76
|
+
if op == "session_status":
|
|
77
|
+
return await self._gateway.session_status()
|
|
78
|
+
raise ValueError(f"unknown op {op!r}")
|
|
79
|
+
|
|
80
|
+
async def _utter(self, op: str, message: dict) -> dict:
|
|
81
|
+
targets = message.get("targets")
|
|
82
|
+
if not isinstance(targets, list) or not targets:
|
|
83
|
+
raise ValueError("targets required")
|
|
84
|
+
text = message.get("text")
|
|
85
|
+
if not isinstance(text, str) or text == "":
|
|
86
|
+
raise ValueError("text required")
|
|
87
|
+
volume = message.get("volume")
|
|
88
|
+
if not isinstance(volume, int):
|
|
89
|
+
volume = 40
|
|
90
|
+
locale = REGION_LOCALE.get(self._gateway.region, "en-GB")
|
|
91
|
+
outcomes: list[dict] = []
|
|
92
|
+
for serial in targets:
|
|
93
|
+
if not isinstance(serial, str):
|
|
94
|
+
outcomes.append({"targetId": "", "ok": False, "error": "bad target"})
|
|
95
|
+
continue
|
|
96
|
+
target = self._gateway.target_for(serial)
|
|
97
|
+
if target is None:
|
|
98
|
+
outcomes.append({"targetId": serial, "ok": False, "error": "unknown echo"})
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
if op == "speak":
|
|
102
|
+
sequence = build_speak_sequence(target, text, volume, locale)
|
|
103
|
+
await self._gateway.speak(sequence, target["customerId"])
|
|
104
|
+
else:
|
|
105
|
+
announcement = build_announcement(target, text, locale)
|
|
106
|
+
await self._gateway.announce(announcement, target["customerId"])
|
|
107
|
+
outcomes.append({"targetId": serial, "ok": True})
|
|
108
|
+
except Exception as err: # noqa: BLE001 — one Echo failing must not drop the others
|
|
109
|
+
outcomes.append({"targetId": serial, "ok": False, "error": str(err)})
|
|
110
|
+
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()
|