@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +18 -0
  3. package/dist/MaskShapeCanvas-B6q5RDLi.mjs +13807 -0
  4. package/dist/MotionZonesSettings-BD57OAIs.mjs +309 -0
  5. package/dist/PrivacyMaskSettings-ZxdPEelf.mjs +368 -0
  6. package/dist/SceneMonitorEditor--KqtKX1B.mjs +846 -0
  7. package/dist/_stub.js +39665 -0
  8. package/dist/_virtual_mf-localSharedImportMap___mfe_internal__addon_import_alexa_page-CdRL7bFZ.mjs +156 -0
  9. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.js-BmoaT3cr.mjs +25 -0
  10. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-I8eNxvdp.mjs +26 -0
  11. 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
  12. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare___mf_0_trpc_mf_1_client__loadShare__.js-BHqOw8oh.mjs +25 -0
  13. 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
  14. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react__loadShare__.js-DwXXYAlk.mjs +70 -0
  15. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-CUiVYNMY.mjs +26 -0
  16. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom__loadShare__.js-CIPmAyPi.mjs +25 -0
  17. package/dist/_virtual_mf___mfe_internal__addon_import_alexa_page__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-tXm1x5EZ.mjs +25 -0
  18. package/dist/addon-import-alexa.css +3 -0
  19. package/dist/addon.js +3151 -650
  20. package/dist/addon.mjs +3167 -669
  21. package/dist/hostInit-_k3o3MHN.mjs +129 -0
  22. package/dist/index.js +1 -0
  23. package/dist/index.mjs +2 -2
  24. package/dist/player-overlays-CJBC2I_E.mjs +51 -0
  25. package/dist/remoteEntry.js +2 -0
  26. package/dist/remoteEntry.ssr.js +33 -0
  27. package/dist/responsive-N99niSJu.mjs +125 -0
  28. package/dist/rolldown-runtime-HEgqtunE.mjs +20 -0
  29. package/dist/scene-monitor-copy-DeuPVYmS.mjs +227 -0
  30. package/dist/square-CnLcG0Zc.mjs +45 -0
  31. package/dist/trash-2-BzimUQsl.mjs +25 -0
  32. package/dist/ui-text-B5YO4hoJ.mjs +34 -0
  33. package/dist/virtualExposes-lZT06jeg.mjs +2 -0
  34. package/dist/virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_import_alexa_page__remoteEntry_js-CsdMNhUS.mjs +2857 -0
  35. package/dist/virtual_mf-exposes-ssr___mfe_internal__addon_import_alexa_page__remoteEntry_js-CViESMdd.mjs +10 -0
  36. package/dist/virtual_mf-exposes___mfe_internal__addon_import_alexa_page__remoteEntry_js-C3qJCjtV.mjs +27 -0
  37. package/i18n/en.json +6 -1
  38. package/i18n/it.json +6 -1
  39. package/package.json +29 -5
  40. package/python/NOTICE +4 -0
  41. package/python/alexa_gateway.py +199 -0
  42. package/python/alexa_protocol.py +110 -0
  43. package/python/alexa_sequences.py +74 -0
  44. package/python/alexa_worker.py +50 -0
  45. package/python/requirements.txt +3 -0
  46. 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 };
@@ -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.147",
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). 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,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,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,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()
@@ -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