@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.
Files changed (48) 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/MaskShapeCanvas-B6q5RDLi.mjs +13807 -0
  5. package/dist/MotionZonesSettings-Dxo0EAzN.mjs +309 -0
  6. package/dist/PrivacyMaskSettings-DwWxpZrj.mjs +368 -0
  7. package/dist/SceneMonitorEditor-CC0dk5TR.mjs +846 -0
  8. package/dist/_stub.js +39566 -0
  9. package/dist/_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CcQjQJmj.mjs +156 -0
  10. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.js-BmoaT3cr.mjs +25 -0
  11. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Msberbw_.mjs +26 -0
  12. 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
  13. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_trpc_mf_1_client__loadShare__.js-BHqOw8oh.mjs +25 -0
  14. 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
  15. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react__loadShare__.js-DwXXYAlk.mjs +70 -0
  16. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-CUiVYNMY.mjs +26 -0
  17. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom__loadShare__.js-CIPmAyPi.mjs +25 -0
  18. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-tXm1x5EZ.mjs +25 -0
  19. package/dist/addon-import-alexa.css +3 -0
  20. package/dist/addon.js +2708 -149
  21. package/dist/addon.mjs +2724 -168
  22. package/dist/hostInit-CRUwUtSm.mjs +129 -0
  23. package/dist/index.js +1 -0
  24. package/dist/index.mjs +2 -2
  25. package/dist/player-overlays-DNpsEqAe.mjs +51 -0
  26. package/dist/remoteEntry.js +2 -0
  27. package/dist/remoteEntry.ssr.js +33 -0
  28. package/dist/responsive-B82v3AbM.mjs +125 -0
  29. package/dist/rolldown-runtime-HEgqtunE.mjs +20 -0
  30. package/dist/scene-monitor-copy-CPnjwzw6.mjs +227 -0
  31. package/dist/square-YTl_L1_3.mjs +45 -0
  32. package/dist/trash-2-DCHugLVt.mjs +25 -0
  33. package/dist/ui-text-B5YO4hoJ.mjs +34 -0
  34. package/dist/virtualExposes-lZT06jeg.mjs +2 -0
  35. package/dist/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-DMdgQOgz.mjs +2857 -0
  36. package/dist/virtual_mf-exposes-ssr___mfe_internal__addon_import_alexa_page__remoteEntry_js-CViESMdd.mjs +10 -0
  37. package/dist/virtual_mf-exposes___mfe_internal__addon_import_alexa_page__remoteEntry_js-C3qJCjtV.mjs +27 -0
  38. package/i18n/en.json +8 -1
  39. package/i18n/it.json +8 -1
  40. package/package.json +26 -4
  41. package/python/NOTICE +4 -0
  42. package/python/alexa_gateway.py +226 -0
  43. package/python/alexa_protocol.py +115 -0
  44. package/python/alexa_sequences.py +74 -0
  45. package/python/alexa_worker.py +50 -0
  46. package/python/requirements.txt +3 -0
  47. package/python/test_alexa_gateway.py +141 -0
  48. 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 };
@@ -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.148",
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). Maps Alexa capability interfaces onto typed CamStack device caps.",
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,4 @@
1
+ Echo speech is driven by alexapy (Apache License 2.0).
2
+ The library is installed from python/requirements.txt; it is not copied into this tree.
3
+ Upstream: https://gitlab.com/keatontaylor/alexapy
4
+ Copyright of alexapy remains with its authors.
@@ -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()
@@ -0,0 +1,3 @@
1
+ # Pinned. Echo login, discovery, speak and announce come from this library.
2
+ # Apache-2.0. Installed by the node from this file; not vendored.
3
+ alexapy==1.29.20