Home Assistant Unofficial Reference 2024.12.1
intent.py
Go to the documentation of this file.
1 """Support for Alexa skill service end point."""
2 
3 from collections.abc import Callable, Coroutine
4 import enum
5 import logging
6 from typing import Any
7 
8 from aiohttp.web import Response
9 
10 from homeassistant.components import http
11 from homeassistant.core import HomeAssistant, callback
12 from homeassistant.exceptions import HomeAssistantError
13 from homeassistant.helpers import intent
14 from homeassistant.util.decorator import Registry
15 
16 from .const import DOMAIN, SYN_RESOLUTION_MATCH
17 
18 _LOGGER = logging.getLogger(__name__)
19 
20 HANDLERS: Registry[
21  str, Callable[[HomeAssistant, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]]]
22 ] = Registry()
23 
24 INTENTS_API_ENDPOINT = "/api/alexa"
25 
26 
27 class SpeechType(enum.StrEnum):
28  """The Alexa speech types."""
29 
30  plaintext = "PlainText"
31  ssml = "SSML"
32 
33 
34 SPEECH_MAPPINGS = {"plain": SpeechType.plaintext, "ssml": SpeechType.ssml}
35 
36 
37 class CardType(enum.StrEnum):
38  """The Alexa card types."""
39 
40  simple = "Simple"
41  link_account = "LinkAccount"
42 
43 
44 @callback
45 def async_setup(hass: HomeAssistant) -> None:
46  """Activate Alexa component."""
47  hass.http.register_view(AlexaIntentsView)
48 
49 
50 async def async_setup_intents(hass: HomeAssistant) -> None:
51  """Do intents setup.
52 
53  Right now this module does not expose any, but the intent component breaks
54  without it.
55  """
56 
57 
59  """When an unknown Alexa request is passed in."""
60 
61 
62 class AlexaIntentsView(http.HomeAssistantView):
63  """Handle Alexa requests."""
64 
65  url = INTENTS_API_ENDPOINT
66  name = "api:alexa"
67 
68  async def post(self, request: http.HomeAssistantRequest) -> Response | bytes:
69  """Handle Alexa."""
70  hass = request.app[http.KEY_HASS]
71  message: dict[str, Any] = await request.json()
72 
73  _LOGGER.debug("Received Alexa request: %s", message)
74 
75  try:
76  response: dict[str, Any] = await async_handle_message(hass, message)
77  return b"" if response is None else self.json(response)
78  except UnknownRequest as err:
79  _LOGGER.warning(str(err))
80  return self.json(intent_error_response(hass, message, str(err)))
81 
82  except intent.UnknownIntent as err:
83  _LOGGER.warning(str(err))
84  return self.json(
86  hass,
87  message,
88  "This intent is not yet configured within Home Assistant.",
89  )
90  )
91 
92  except intent.InvalidSlotInfo as err:
93  _LOGGER.error("Received invalid slot data from Alexa: %s", err)
94  return self.json(
96  hass, message, "Invalid slot information received for this intent."
97  )
98  )
99 
100  except intent.IntentError:
101  _LOGGER.exception("Error handling intent")
102  return self.json(
103  intent_error_response(hass, message, "Error handling intent.")
104  )
105 
106 
108  hass: HomeAssistant, message: dict[str, Any], error: str
109 ) -> dict[str, Any]:
110  """Return an Alexa response that will speak the error message."""
111  alexa_intent_info = message["request"].get("intent")
112  alexa_response = AlexaIntentResponse(hass, alexa_intent_info)
113  alexa_response.add_speech(SpeechType.plaintext, error)
114  return alexa_response.as_dict()
115 
116 
118  hass: HomeAssistant, message: dict[str, Any]
119 ) -> dict[str, Any]:
120  """Handle an Alexa intent.
121 
122  Raises:
123  - UnknownRequest
124  - intent.UnknownIntent
125  - intent.InvalidSlotInfo
126  - intent.IntentError
127 
128  """
129  req = message["request"]
130  req_type = req["type"]
131 
132  if not (handler := HANDLERS.get(req_type)):
133  raise UnknownRequest(f"Received unknown request {req_type}")
134 
135  return await handler(hass, message)
136 
137 
138 @HANDLERS.register("SessionEndedRequest")
139 @HANDLERS.register("IntentRequest")
140 @HANDLERS.register("LaunchRequest")
142  hass: HomeAssistant, message: dict[str, Any]
143 ) -> dict[str, Any]:
144  """Handle an intent request.
145 
146  Raises:
147  - intent.UnknownIntent
148  - intent.InvalidSlotInfo
149  - intent.IntentError
150 
151  """
152  req = message["request"]
153  alexa_intent_info = req.get("intent")
154  alexa_response = AlexaIntentResponse(hass, alexa_intent_info)
155 
156  if req["type"] == "LaunchRequest":
157  intent_name = (
158  message.get("session", {}).get("application", {}).get("applicationId")
159  )
160  elif req["type"] == "SessionEndedRequest":
161  app_id = message.get("session", {}).get("application", {}).get("applicationId")
162  intent_name = f"{app_id}.{req['type']}"
163  alexa_response.variables["reason"] = req["reason"]
164  alexa_response.variables["error"] = req.get("error")
165  else:
166  intent_name = alexa_intent_info["name"]
167 
168  intent_response = await intent.async_handle(
169  hass,
170  DOMAIN,
171  intent_name,
172  {key: {"value": value} for key, value in alexa_response.variables.items()},
173  )
174 
175  for intent_speech, alexa_speech in SPEECH_MAPPINGS.items():
176  if intent_speech in intent_response.speech:
177  alexa_response.add_speech(
178  alexa_speech, intent_response.speech[intent_speech]["speech"]
179  )
180  if intent_speech in intent_response.reprompt:
181  alexa_response.add_reprompt(
182  alexa_speech, intent_response.reprompt[intent_speech]["reprompt"]
183  )
184 
185  if "simple" in intent_response.card:
186  alexa_response.add_card(
187  CardType.simple,
188  intent_response.card["simple"]["title"],
189  intent_response.card["simple"]["content"],
190  )
191 
192  return alexa_response.as_dict()
193 
194 
195 def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]:
196  """Check slot request for synonym resolutions."""
197  # Default to the spoken slot value if more than one or none are found. Always
198  # passes the id and name of the nearest possible slot resolution. For
199  # reference to the request object structure, see the Alexa docs:
200  # https://tinyurl.com/ybvm7jhs
201  resolved_data: dict[str, Any] = {}
202  resolved_data["value"] = request["value"]
203  resolved_data["id"] = ""
204 
205  if (
206  "resolutions" in request
207  and "resolutionsPerAuthority" in request["resolutions"]
208  and len(request["resolutions"]["resolutionsPerAuthority"]) >= 1
209  ):
210  # Extract all of the possible values from each authority with a
211  # successful match
212  possible_values = []
213 
214  for entry in request["resolutions"]["resolutionsPerAuthority"]:
215  if entry["status"]["code"] != SYN_RESOLUTION_MATCH:
216  continue
217 
218  possible_values.extend([item["value"] for item in entry["values"]])
219 
220  # Always set id if available, otherwise an empty string is used as id
221  if len(possible_values) >= 1:
222  # Set ID if available
223  if "id" in possible_values[0]:
224  resolved_data["id"] = possible_values[0]["id"]
225 
226  # If there is only one match use the resolved value, otherwise the
227  # resolution cannot be determined, so use the spoken slot value and empty string as id
228  if len(possible_values) == 1:
229  resolved_data["value"] = possible_values[0]["name"]
230  else:
231  _LOGGER.debug(
232  "Found multiple synonym resolutions for slot value: {%s: %s}",
233  key,
234  resolved_data["value"],
235  )
236 
237  return resolved_data
238 
239 
241  """Help generating the response for Alexa."""
242 
243  def __init__(self, hass: HomeAssistant, intent_info: dict[str, Any] | None) -> None:
244  """Initialize the response."""
245  self.hasshass = hass
246  self.speechspeech: dict[str, Any] | None = None
247  self.cardcard: dict[str, Any] | None = None
248  self.repromptreprompt: dict[str, Any] | None = None
249  self.session_attributes: dict[str, Any] = {}
250  self.should_end_sessionshould_end_session = True
251  self.variables: dict[str, Any] = {}
252 
253  # Intent is None if request was a LaunchRequest or SessionEndedRequest
254  if intent_info is not None:
255  for key, value in intent_info.get("slots", {}).items():
256  # Only include slots with values
257  if "value" not in value:
258  continue
259 
260  _key = key.replace(".", "_")
261  _slot_data = resolve_slot_data(key, value)
262 
263  self.variables[_key] = _slot_data["value"]
264  self.variables[_key + "_Id"] = _slot_data["id"]
265 
266  def add_card(self, card_type: CardType, title: str, content: str) -> None:
267  """Add a card to the response."""
268  assert self.cardcard is None
269 
270  card = {"type": card_type.value}
271 
272  if card_type == CardType.link_account:
273  self.cardcard = card
274  return
275 
276  card["title"] = title
277  card["content"] = content
278  self.cardcard = card
279 
280  def add_speech(self, speech_type: SpeechType, text: str) -> None:
281  """Add speech to the response."""
282  assert self.speechspeech is None
283 
284  key = "ssml" if speech_type == SpeechType.ssml else "text"
285 
286  self.speechspeech = {"type": speech_type.value, key: text}
287 
288  def add_reprompt(self, speech_type: SpeechType, text: str) -> None:
289  """Add reprompt if user does not answer."""
290  assert self.repromptreprompt is None
291 
292  key = "ssml" if speech_type == SpeechType.ssml else "text"
293 
294  self.should_end_sessionshould_end_session = False
295 
296  self.repromptreprompt = {"type": speech_type.value, key: text}
297 
298  def as_dict(self) -> dict[str, Any]:
299  """Return response in an Alexa valid dict."""
300  response: dict[str, Any] = {"shouldEndSession": self.should_end_sessionshould_end_session}
301 
302  if self.cardcard is not None:
303  response["card"] = self.cardcard
304 
305  if self.speechspeech is not None:
306  response["outputSpeech"] = self.speechspeech
307 
308  if self.repromptreprompt is not None:
309  response["reprompt"] = {"outputSpeech": self.repromptreprompt}
310 
311  return {
312  "version": "1.0",
313  "sessionAttributes": self.session_attributes,
314  "response": response,
315  }
None add_speech(self, SpeechType speech_type, str text)
Definition: intent.py:280
None __init__(self, HomeAssistant hass, dict[str, Any]|None intent_info)
Definition: intent.py:243
None add_card(self, CardType card_type, str title, str content)
Definition: intent.py:266
None add_reprompt(self, SpeechType speech_type, str text)
Definition: intent.py:288
Response|bytes post(self, http.HomeAssistantRequest request)
Definition: intent.py:68
dict[str, str] resolve_slot_data(str key, dict[str, Any] request)
Definition: intent.py:195
dict[str, Any] intent_error_response(HomeAssistant hass, dict[str, Any] message, str error)
Definition: intent.py:109
dict[str, Any] async_handle_intent(HomeAssistant hass, dict[str, Any] message)
Definition: intent.py:143
dict[str, Any] async_handle_message(HomeAssistant hass, dict[str, Any] message)
Definition: intent.py:119
None async_setup_intents(HomeAssistant hass)
Definition: intent.py:50
None async_setup(HomeAssistant hass)
Definition: intent.py:45
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88