@camstack/addon-import-alexa 0.2.175 → 0.2.177

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/i18n/en.json CHANGED
@@ -14,7 +14,6 @@
14
14
  "echoTarget.s_echo.title": "Echo",
15
15
  "echoTarget.s_echo.brokerId.label": "Amazon account",
16
16
  "echoTarget.s_echo.echoId.label": "Echo",
17
- "echoTarget.s_echo.volume.label": "Volume",
18
17
  "echoTarget.s_echo.respectDnd.label": "Respect Do Not Disturb",
19
18
  "globalSettings": {
20
19
  "s_alexa-proxy-login": {
package/i18n/it.json CHANGED
@@ -14,7 +14,6 @@
14
14
  "echoTarget.s_echo.title": "Echo",
15
15
  "echoTarget.s_echo.brokerId.label": "Account Amazon",
16
16
  "echoTarget.s_echo.echoId.label": "Echo",
17
- "echoTarget.s_echo.volume.label": "Volume",
18
17
  "echoTarget.s_echo.respectDnd.label": "Rispetta Non disturbare",
19
18
  "globalSettings": {
20
19
  "s_alexa-proxy-login": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.175",
3
+ "version": "0.2.177",
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",
@@ -68,7 +68,7 @@
68
68
  }
69
69
  ],
70
70
  "requires": {
71
- "server": ">=1.2.723"
71
+ "server": ">=1.2.730"
72
72
  }
73
73
  },
74
74
  "files": [
@@ -69,6 +69,9 @@ def normalize_device(raw: dict) -> dict | None:
69
69
  return result
70
70
 
71
71
 
72
+ # The skill alexapy itself names for an AlexaAnnouncement node.
73
+ ANNOUNCEMENT_SKILL_ID = "amzn1.ask.1p.routines.messaging"
74
+
72
75
  class AlexaPyGateway:
73
76
  """Holds one AlexaLogin for the life of the process (one process per broker)."""
74
77
 
@@ -177,13 +180,28 @@ class AlexaPyGateway:
177
180
  async def speak(self, sequence: dict, customer_id: str) -> None:
178
181
  login = self._require_login()
179
182
  _AlexaLogin, AlexaAPI = _import_alexapy()
180
- await AlexaAPI.send_sequence(login, sequence, customer_id, None)
183
+ # alexapy 1.29.20: send_sequence/send_announcement are INSTANCE methods that read the
184
+ # device behind the API object (type, serial, locale). Our payloads already carry the
185
+ # target, so run the prebuilt node through run_behavior, which needs only the login.
186
+ # queue_delay=0 posts at once instead of batching with other queued nodes.
187
+ await AlexaAPI(None, login).run_behavior(sequence["startNode"], queue_delay=0)
181
188
  self._touch_success()
182
189
 
183
190
  async def announce(self, announcement: dict, customer_id: str) -> None:
184
191
  login = self._require_login()
185
192
  _AlexaLogin, AlexaAPI = _import_alexapy()
186
- await AlexaAPI.send_announcement(login, announcement, customer_id, None)
193
+ node = {
194
+ "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
195
+ "type": "AlexaAnnouncement",
196
+ "operationPayload": {
197
+ "expireAfter": announcement["expireAfter"],
198
+ "content": announcement["content"],
199
+ "target": announcement["target"],
200
+ "customerId": customer_id,
201
+ "skillId": ANNOUNCEMENT_SKILL_ID,
202
+ },
203
+ }
204
+ await AlexaAPI(None, login).run_behavior(node, queue_delay=0)
187
205
  self._touch_success()
188
206
 
189
207
  async def session_status(self) -> dict:
@@ -89,9 +89,6 @@ class Session:
89
89
  text = message.get("text")
90
90
  if not isinstance(text, str) or text == "":
91
91
  raise ValueError("text required")
92
- volume = message.get("volume")
93
- if not isinstance(volume, int):
94
- volume = 40
95
92
  locale = REGION_LOCALE.get(self._gateway.region, "en-GB")
96
93
  outcomes: list[dict] = []
97
94
  for serial in targets:
@@ -104,7 +101,7 @@ class Session:
104
101
  continue
105
102
  try:
106
103
  if op == "speak":
107
- sequence = build_speak_sequence(target, text, volume, locale)
104
+ sequence = build_speak_sequence(target, text, locale)
108
105
  await self._gateway.speak(sequence, target["customerId"])
109
106
  else:
110
107
  announcement = build_announcement(target, text, locale)
@@ -29,27 +29,12 @@ def _speak_node(target: EchoTarget, text: str, locale: str) -> dict:
29
29
  }
30
30
 
31
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."""
32
+ def build_speak_sequence(target: EchoTarget, text: str, locale: str) -> dict:
33
+ """Speech at the Echo's current volume. Speak honours Do Not Disturb on the device."""
48
34
  speak = _speak_node(target, text, locale)
49
- start = _volume_node(target, volume, speak) if volume >= 0 else speak
50
35
  return {
51
36
  "@type": "com.amazon.alexa.behaviors.model.Sequence",
52
- "startNode": start,
37
+ "startNode": speak,
53
38
  }
54
39
 
55
40
 
@@ -0,0 +1,83 @@
1
+ """speak/announce must call alexapy the way alexapy 1.29.20 defines it.
2
+
3
+ In that release send_sequence / send_announcement are INSTANCE methods that read
4
+ `self._device`; calling them on the class with a login first (the old code) fails
5
+ with "'AlexaLogin' object has no attribute '_device'". The fake below mirrors the
6
+ real signatures, so the wrong call shape raises the same way.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import sys
13
+ import unittest
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
17
+
18
+ from alexa_gateway import AlexaPyGateway
19
+ from alexa_sequences import build_announcement, build_speak_sequence
20
+
21
+ TARGET = {"id": "G090XX", "deviceType": "A3S5BH2HU6VAYF", "customerId": "A1CUST"}
22
+
23
+
24
+ def make_fake_api(posted: list):
25
+ class FakeAlexaAPI:
26
+ def __init__(self, device, login):
27
+ self._device = device
28
+ self._login = login
29
+
30
+ async def run_behavior(self, node_data, queue_delay: float = 1.5) -> None:
31
+ posted.append((node_data, queue_delay))
32
+
33
+ # Real signatures: the first positional parameter is `self`, then message/sequence.
34
+ async def send_sequence(self, sequence, customer_id=None, queue_delay=1.5, extra=None, **kwargs):
35
+ self._device._device_type # raises AttributeError without a device, as the real one
36
+
37
+ async def send_announcement(self, message, method="all", title="Announcement",
38
+ customer_id=None, targets=None, queue_delay=1.5, extra=None):
39
+ self._device._locale
40
+
41
+ return FakeAlexaAPI
42
+
43
+
44
+ class SendTests(unittest.TestCase):
45
+ def _gateway(self, posted):
46
+ import types
47
+
48
+ package = types.ModuleType("alexapy")
49
+ package.AlexaLogin = object
50
+ package.AlexaAPI = make_fake_api(posted)
51
+ sys.modules["alexapy"] = package
52
+ sys.modules["alexapy.alexalogin"] = types.ModuleType("alexapy.alexalogin")
53
+ gateway = AlexaPyGateway()
54
+ gateway._login = object()
55
+ return gateway
56
+
57
+ def tearDown(self):
58
+ sys.modules.pop("alexapy", None)
59
+ sys.modules.pop("alexapy.alexalogin", None)
60
+
61
+ def test_speak_posts_the_sequence_start_node_immediately(self):
62
+ posted: list = []
63
+ sequence = build_speak_sequence(TARGET, "hello", 30, "it-IT")
64
+ asyncio.run(self._gateway(posted).speak(sequence, "A1CUST"))
65
+ self.assertEqual(posted, [(sequence["startNode"], 0)])
66
+
67
+ def test_announce_posts_an_announcement_node_with_target_and_content(self):
68
+ posted: list = []
69
+ announcement = build_announcement(TARGET, "hello", "it-IT")
70
+ asyncio.run(self._gateway(posted).announce(announcement, "A1CUST"))
71
+ self.assertEqual(len(posted), 1)
72
+ node, delay = posted[0]
73
+ self.assertEqual(delay, 0)
74
+ self.assertEqual(node["type"], "AlexaAnnouncement")
75
+ payload = node["operationPayload"]
76
+ self.assertEqual(payload["target"], announcement["target"])
77
+ self.assertEqual(payload["content"], announcement["content"])
78
+ self.assertEqual(payload["customerId"], "A1CUST")
79
+ self.assertEqual(payload["skillId"], "amzn1.ask.1p.routines.messaging")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ unittest.main()
@@ -136,19 +136,20 @@ class ProtocolTests(unittest.TestCase):
136
136
  session = Session(gateway)
137
137
  spoken = run(
138
138
  session,
139
- {"id": "4", "op": "speak", "targets": ["echo-1"], "text": "Person at the door", "volume": 30},
139
+ {"id": "4", "op": "speak", "targets": ["echo-1"], "text": "Person at the door"},
140
140
  )
141
141
  self.assertTrue(spoken["result"]["outcomes"][0]["ok"])
142
142
  sequence = gateway.spoken[0]["sequence"]
143
- self.assertEqual(sequence["startNode"]["type"], "Alexa.DeviceControls.Volume")
144
- self.assertEqual(sequence["startNode"]["next"]["type"], "Alexa.Speak")
143
+ # No volume node: the Echo speaks at its current volume.
144
+ self.assertEqual(sequence["startNode"]["type"], "Alexa.Speak")
145
+ self.assertNotIn("next", sequence["startNode"])
145
146
  self.assertEqual(
146
- sequence["startNode"]["next"]["operationPayload"]["textToSpeak"],
147
+ sequence["startNode"]["operationPayload"]["textToSpeak"],
147
148
  "Person at the door",
148
149
  )
149
150
  announced = run(
150
151
  session,
151
- {"id": "5", "op": "announce", "targets": ["echo-1"], "text": "Hello", "volume": 30},
152
+ {"id": "5", "op": "announce", "targets": ["echo-1"], "text": "Hello"},
152
153
  )
153
154
  self.assertTrue(announced["result"]["outcomes"][0]["ok"])
154
155
  self.assertEqual(gateway.announced[0]["content"][0]["speak"]["value"], "Hello")
@@ -167,7 +168,7 @@ class ProtocolTests(unittest.TestCase):
167
168
 
168
169
  def test_sequence_helpers_do_not_embed_markup(self) -> None:
169
170
  target = {"id": "echo-1", "deviceType": "T", "customerId": "c"}
170
- sequence = build_speak_sequence(target, "plain", 10, "en-GB")
171
+ sequence = build_speak_sequence(target, "plain", "en-GB")
171
172
  self.assertNotIn("<", json.dumps(sequence))
172
173
  announcement = build_announcement(target, "plain", "en-GB")
173
174
  self.assertEqual(announcement["content"][0]["speak"]["type"], "text")