Home Assistant Unofficial Reference 2024.12.1
intent.py
Go to the documentation of this file.
1 """Module to coordinate user intentions."""
2 
3 from __future__ import annotations
4 
5 from abc import abstractmethod
6 import asyncio
7 from collections.abc import Callable, Collection, Coroutine, Iterable
8 import dataclasses
9 from dataclasses import dataclass, field
10 from enum import Enum, StrEnum, auto
11 from itertools import groupby
12 import logging
13 from typing import Any
14 
15 from propcache import cached_property
16 import voluptuous as vol
17 
18 from homeassistant.components.homeassistant.exposed_entities import async_should_expose
19 from homeassistant.const import (
20  ATTR_DEVICE_CLASS,
21  ATTR_ENTITY_ID,
22  ATTR_SUPPORTED_FEATURES,
23 )
24 from homeassistant.core import Context, HomeAssistant, State, callback
25 from homeassistant.exceptions import HomeAssistantError
26 from homeassistant.loader import bind_hass
27 from homeassistant.util.hass_dict import HassKey
28 
29 from . import (
30  area_registry,
31  config_validation as cv,
32  device_registry,
33  entity_registry,
34  floor_registry,
35 )
36 from .typing import VolSchemaType
37 
38 _LOGGER = logging.getLogger(__name__)
39 type _SlotsType = dict[str, Any]
40 type _IntentSlotsType = dict[
41  str | tuple[str, str], VolSchemaType | Callable[[Any], Any]
42 ]
43 
44 INTENT_TURN_OFF = "HassTurnOff"
45 INTENT_TURN_ON = "HassTurnOn"
46 INTENT_TOGGLE = "HassToggle"
47 INTENT_GET_STATE = "HassGetState"
48 INTENT_NEVERMIND = "HassNevermind"
49 INTENT_SET_POSITION = "HassSetPosition"
50 INTENT_START_TIMER = "HassStartTimer"
51 INTENT_CANCEL_TIMER = "HassCancelTimer"
52 INTENT_CANCEL_ALL_TIMERS = "HassCancelAllTimers"
53 INTENT_INCREASE_TIMER = "HassIncreaseTimer"
54 INTENT_DECREASE_TIMER = "HassDecreaseTimer"
55 INTENT_PAUSE_TIMER = "HassPauseTimer"
56 INTENT_UNPAUSE_TIMER = "HassUnpauseTimer"
57 INTENT_TIMER_STATUS = "HassTimerStatus"
58 INTENT_GET_CURRENT_DATE = "HassGetCurrentDate"
59 INTENT_GET_CURRENT_TIME = "HassGetCurrentTime"
60 INTENT_RESPOND = "HassRespond"
61 
62 SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA)
63 
64 DATA_KEY: HassKey[dict[str, IntentHandler]] = HassKey("intent")
65 
66 SPEECH_TYPE_PLAIN = "plain"
67 SPEECH_TYPE_SSML = "ssml"
68 
69 
70 @callback
71 @bind_hass
72 def async_register(hass: HomeAssistant, handler: IntentHandler) -> None:
73  """Register an intent with Home Assistant."""
74  if (intents := hass.data.get(DATA_KEY)) is None:
75  intents = {}
76  hass.data[DATA_KEY] = intents
77 
78  assert getattr(handler, "intent_type", None), "intent_type should be set"
79 
80  if handler.intent_type in intents:
81  _LOGGER.warning(
82  "Intent %s is being overwritten by %s", handler.intent_type, handler
83  )
84 
85  intents[handler.intent_type] = handler
86 
87 
88 @callback
89 @bind_hass
90 def async_remove(hass: HomeAssistant, intent_type: str) -> None:
91  """Remove an intent from Home Assistant."""
92  if (intents := hass.data.get(DATA_KEY)) is None:
93  return
94 
95  intents.pop(intent_type, None)
96 
97 
98 @callback
99 def async_get(hass: HomeAssistant) -> Iterable[IntentHandler]:
100  """Return registered intents."""
101  return hass.data.get(DATA_KEY, {}).values()
102 
103 
104 @bind_hass
105 async def async_handle(
106  hass: HomeAssistant,
107  platform: str,
108  intent_type: str,
109  slots: _SlotsType | None = None,
110  text_input: str | None = None,
111  context: Context | None = None,
112  language: str | None = None,
113  assistant: str | None = None,
114  device_id: str | None = None,
115  conversation_agent_id: str | None = None,
116 ) -> IntentResponse:
117  """Handle an intent."""
118  handler = hass.data.get(DATA_KEY, {}).get(intent_type)
119 
120  if handler is None:
121  raise UnknownIntent(f"Unknown intent {intent_type}")
122 
123  if context is None:
124  context = Context()
125 
126  if language is None:
127  language = hass.config.language
128 
129  intent = Intent(
130  hass,
131  platform=platform,
132  intent_type=intent_type,
133  slots=slots or {},
134  text_input=text_input,
135  context=context,
136  language=language,
137  assistant=assistant,
138  device_id=device_id,
139  conversation_agent_id=conversation_agent_id,
140  )
141 
142  try:
143  _LOGGER.info("Triggering intent handler %s", handler)
144  result = await handler.async_handle(intent)
145  except vol.Invalid as err:
146  _LOGGER.warning("Received invalid slot info for %s: %s", intent_type, err)
147  raise InvalidSlotInfo(f"Received invalid slot info for {intent_type}") from err
148  except IntentError:
149  raise # bubble up intent related errors
150  except Exception as err:
151  _LOGGER.exception("Error handling %s", intent_type)
152  raise IntentUnexpectedError(f"Error handling {intent_type}") from err
153  return result
154 
155 
157  """Base class for intent related errors."""
158 
159 
161  """When the intent is not registered."""
162 
163 
165  """When the slot data is invalid."""
166 
167 
169  """Error while handling intent."""
170 
171  def __init__(self, message: str = "", response_key: str | None = None) -> None:
172  """Initialize error."""
173  super().__init__(message)
174  self.response_keyresponse_key = response_key
175 
176 
178  """Unexpected error while handling intent."""
179 
180 
181 class MatchFailedReason(Enum):
182  """Possible reasons for match failure in async_match_targets."""
183 
184  NAME = auto()
185  """No entities matched name constraint."""
186 
187  AREA = auto()
188  """No entities matched area constraint."""
189 
190  FLOOR = auto()
191  """No entities matched floor constraint."""
192 
193  DOMAIN = auto()
194  """No entities matched domain constraint."""
195 
196  DEVICE_CLASS = auto()
197  """No entities matched device class constraint."""
198 
199  FEATURE = auto()
200  """No entities matched supported features constraint."""
201 
202  STATE = auto()
203  """No entities matched required states constraint."""
204 
205  ASSISTANT = auto()
206  """No entities matched exposed to assistant constraint."""
207 
208  INVALID_AREA = auto()
209  """Area name from constraint does not exist."""
210 
211  INVALID_FLOOR = auto()
212  """Floor name from constraint does not exist."""
213 
214  DUPLICATE_NAME = auto()
215  """Two or more entities matched the same name constraint and could not be disambiguated."""
216 
217  def is_no_entities_reason(self) -> bool:
218  """Return True if the match failed because no entities matched."""
219  return self not in (
220  MatchFailedReason.INVALID_AREA,
221  MatchFailedReason.INVALID_FLOOR,
222  MatchFailedReason.DUPLICATE_NAME,
223  )
224 
225 
226 @dataclass
228  """Constraints for async_match_targets."""
229 
230  name: str | None = None
231  """Entity name or alias."""
232 
233  area_name: str | None = None
234  """Area name, id, or alias."""
235 
236  floor_name: str | None = None
237  """Floor name, id, or alias."""
238 
239  domains: Collection[str] | None = None
240  """Domain names."""
241 
242  device_classes: Collection[str] | None = None
243  """Device class names."""
244 
245  features: int | None = None
246  """Required supported features."""
247 
248  states: Collection[str] | None = None
249  """Required states for entities."""
250 
251  assistant: str | None = None
252  """Name of assistant that entities should be exposed to."""
253 
254  allow_duplicate_names: bool = False
255  """True if entities with duplicate names are allowed in result."""
256 
257  @property
258  def has_constraints(self) -> bool:
259  """Returns True if at least one constraint is set (ignores assistant)."""
260  return bool(
261  self.name
262  or self.area_name
263  or self.floor_name
264  or self.domains
265  or self.device_classes
266  or self.features
267  or self.states
268  )
269 
270 
271 @dataclass
273  """Preferences used to disambiguate duplicate name matches in async_match_targets."""
274 
275  area_id: str | None = None
276  """Id of area to use when deduplicating names."""
277 
278  floor_id: str | None = None
279  """Id of floor to use when deduplicating names."""
280 
281 
282 @dataclass
284  """Result from async_match_targets."""
285 
286  is_match: bool
287  """True if one or more entities matched."""
288 
289  no_match_reason: MatchFailedReason | None = None
290  """Reason for failed match when is_match = False."""
291 
292  states: list[State] = field(default_factory=list)
293  """List of matched entity states when is_match = True."""
294 
295  no_match_name: str | None = None
296  """Name of invalid area/floor or duplicate name when match fails for those reasons."""
297 
298  areas: list[area_registry.AreaEntry] = field(default_factory=list)
299  """Areas that were targeted."""
300 
301  floors: list[floor_registry.FloorEntry] = field(default_factory=list)
302  """Floors that were targeted."""
303 
304 
306  """Error when target matching fails."""
307 
308  def __init__(
309  self,
310  result: MatchTargetsResult,
311  constraints: MatchTargetsConstraints,
312  preferences: MatchTargetsPreferences | None = None,
313  ) -> None:
314  """Initialize error."""
315  super().__init__()
316 
317  self.resultresult = result
318  self.constraintsconstraints = constraints
319  self.preferencespreferences = preferences
320 
321  def __str__(self) -> str:
322  """Return string representation."""
323  return f"<MatchFailedError result={self.result}, constraints={self.constraints}, preferences={self.preferences}>"
324 
325 
327  """Error when no states match the intent's constraints."""
328 
329  def __init__(
330  self,
331  reason: MatchFailedReason,
332  name: str | None = None,
333  area: str | None = None,
334  floor: str | None = None,
335  domains: set[str] | None = None,
336  device_classes: set[str] | None = None,
337  ) -> None:
338  """Initialize error."""
339  super().__init__(
340  result=MatchTargetsResult(False, reason),
341  constraints=MatchTargetsConstraints(
342  name=name,
343  area_name=area,
344  floor_name=floor,
345  domains=domains,
346  device_classes=device_classes,
347  ),
348  )
349 
350 
351 @dataclass
353  """Candidate for async_match_targets."""
354 
355  state: State
356  is_exposed: bool
357  entity: entity_registry.RegistryEntry | None = None
358  area: area_registry.AreaEntry | None = None
359  floor: floor_registry.FloorEntry | None = None
360  device: device_registry.DeviceEntry | None = None
361  matched_name: str | None = None
362 
363 
365  name: str, areas: area_registry.AreaRegistry
366 ) -> Iterable[area_registry.AreaEntry]:
367  """Find all areas matching a name (including aliases)."""
368  name_norm = _normalize_name(name)
369  for area in areas.async_list_areas():
370  # Accept name or area id
371  if (area.id == name) or (_normalize_name(area.name) == name_norm):
372  yield area
373  continue
374 
375  if not area.aliases:
376  continue
377 
378  for alias in area.aliases:
379  if _normalize_name(alias) == name_norm:
380  yield area
381  break
382 
383 
385  name: str, floors: floor_registry.FloorRegistry
386 ) -> Iterable[floor_registry.FloorEntry]:
387  """Find all floors matching a name (including aliases)."""
388  name_norm = _normalize_name(name)
389  for floor in floors.async_list_floors():
390  # Accept name or floor id
391  if (floor.floor_id == name) or (_normalize_name(floor.name) == name_norm):
392  yield floor
393  continue
394 
395  if not floor.aliases:
396  continue
397 
398  for alias in floor.aliases:
399  if _normalize_name(alias) == name_norm:
400  yield floor
401  break
402 
403 
404 def _normalize_name(name: str) -> str:
405  """Normalize name for comparison."""
406  return name.strip().casefold()
407 
408 
410  name: str,
411  candidates: Iterable[MatchTargetsCandidate],
412 ) -> Iterable[MatchTargetsCandidate]:
413  """Filter candidates by name."""
414  name_norm = _normalize_name(name)
415 
416  for candidate in candidates:
417  # Accept name or entity id
418  if (candidate.state.entity_id == name) or _normalize_name(
419  candidate.state.name
420  ) == name_norm:
421  candidate.matched_name = name
422  yield candidate
423  continue
424 
425  if candidate.entity is None:
426  continue
427 
428  if candidate.entity.name and (
429  _normalize_name(candidate.entity.name) == name_norm
430  ):
431  candidate.matched_name = name
432  yield candidate
433  continue
434 
435  # Check aliases
436  if candidate.entity.aliases:
437  for alias in candidate.entity.aliases:
438  if _normalize_name(alias) == name_norm:
439  candidate.matched_name = name
440  yield candidate
441  break
442 
443 
445  features: int,
446  candidates: Iterable[MatchTargetsCandidate],
447 ) -> Iterable[MatchTargetsCandidate]:
448  """Filter candidates by supported features."""
449  for candidate in candidates:
450  if (candidate.entity is not None) and (
451  (candidate.entity.supported_features & features) == features
452  ):
453  yield candidate
454  continue
455 
456  supported_features = candidate.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
457  if (supported_features & features) == features:
458  yield candidate
459 
460 
462  device_classes: Iterable[str],
463  candidates: Iterable[MatchTargetsCandidate],
464 ) -> Iterable[MatchTargetsCandidate]:
465  """Filter candidates by device classes."""
466  for candidate in candidates:
467  if (
468  (candidate.entity is not None)
469  and candidate.entity.device_class
470  and (candidate.entity.device_class in device_classes)
471  ):
472  yield candidate
473  continue
474 
475  device_class = candidate.state.attributes.get(ATTR_DEVICE_CLASS)
476  if device_class and (device_class in device_classes):
477  yield candidate
478 
479 
481  areas: area_registry.AreaRegistry,
483  candidates: Iterable[MatchTargetsCandidate],
484 ) -> None:
485  """Add area and device entries to match candidates."""
486  for candidate in candidates:
487  if candidate.entity is None:
488  continue
489 
490  if candidate.entity.device_id:
491  candidate.device = devices.async_get(candidate.entity.device_id)
492 
493  if candidate.entity.area_id:
494  # Use entity area first
495  candidate.area = areas.async_get_area(candidate.entity.area_id)
496  assert candidate.area is not None
497  elif (candidate.device is not None) and candidate.device.area_id:
498  # Fall back to device area
499  candidate.area = areas.async_get_area(candidate.device.area_id)
500 
501 
502 @callback
503 def async_match_targets( # noqa: C901
504  hass: HomeAssistant,
505  constraints: MatchTargetsConstraints,
506  preferences: MatchTargetsPreferences | None = None,
507  states: list[State] | None = None,
508 ) -> MatchTargetsResult:
509  """Match entities based on constraints in order to handle an intent."""
510  preferences = preferences or MatchTargetsPreferences()
511  filtered_by_domain = False
512 
513  if not states:
514  # Get all states and filter by domain
515  states = hass.states.async_all(constraints.domains)
516  filtered_by_domain = True
517  if not states:
518  return MatchTargetsResult(False, MatchFailedReason.DOMAIN)
519 
520  candidates = [
522  state=state,
523  is_exposed=(
524  async_should_expose(hass, constraints.assistant, state.entity_id)
525  if constraints.assistant
526  else True
527  ),
528  )
529  for state in states
530  ]
531 
532  if constraints.domains and (not filtered_by_domain):
533  # Filter by domain (if we didn't already do it)
534  candidates = [c for c in candidates if c.state.domain in constraints.domains]
535  if not candidates:
536  return MatchTargetsResult(False, MatchFailedReason.DOMAIN)
537 
538  if constraints.states:
539  # Filter by state
540  candidates = [c for c in candidates if c.state.state in constraints.states]
541  if not candidates:
542  return MatchTargetsResult(False, MatchFailedReason.STATE)
543 
544  # Try to exit early so we can avoid registry lookups
545  if not (
546  constraints.name
547  or constraints.features
548  or constraints.device_classes
549  or constraints.area_name
550  or constraints.floor_name
551  ):
552  if constraints.assistant:
553  # Check exposure
554  candidates = [c for c in candidates if c.is_exposed]
555  if not candidates:
556  return MatchTargetsResult(False, MatchFailedReason.ASSISTANT)
557 
558  return MatchTargetsResult(True, states=[c.state for c in candidates])
559 
560  # We need entity registry entries now
561  er = entity_registry.async_get(hass)
562  for candidate in candidates:
563  candidate.entity = er.async_get(candidate.state.entity_id)
564 
565  if constraints.name:
566  # Filter by entity name or alias
567  candidates = list(_filter_by_name(constraints.name, candidates))
568  if not candidates:
569  return MatchTargetsResult(False, MatchFailedReason.NAME)
570 
571  if constraints.features:
572  # Filter by supported features
573  candidates = list(_filter_by_features(constraints.features, candidates))
574  if not candidates:
575  return MatchTargetsResult(False, MatchFailedReason.FEATURE)
576 
577  if constraints.device_classes:
578  # Filter by device class
579  candidates = list(
580  _filter_by_device_classes(constraints.device_classes, candidates)
581  )
582  if not candidates:
583  return MatchTargetsResult(False, MatchFailedReason.DEVICE_CLASS)
584 
585  # Check floor/area constraints
586  targeted_floors: list[floor_registry.FloorEntry] | None = None
587  targeted_areas: list[area_registry.AreaEntry] | None = None
588 
589  # True when area information has been added to candidates
590  areas_added = False
591 
592  if constraints.floor_name or constraints.area_name:
593  ar = area_registry.async_get(hass)
594  dr = device_registry.async_get(hass)
595  _add_areas(ar, dr, candidates)
596  areas_added = True
597 
598  if constraints.floor_name:
599  # Filter by areas associated with floor
600  fr = floor_registry.async_get(hass)
601  targeted_floors = list(find_floors(constraints.floor_name, fr))
602  if not targeted_floors:
603  return MatchTargetsResult(
604  False,
605  MatchFailedReason.INVALID_FLOOR,
606  no_match_name=constraints.floor_name,
607  )
608 
609  possible_floor_ids = {floor.floor_id for floor in targeted_floors}
610  possible_area_ids = {
611  area.id
612  for area in ar.async_list_areas()
613  if area.floor_id in possible_floor_ids
614  }
615 
616  candidates = [
617  c
618  for c in candidates
619  if (c.area is not None) and (c.area.id in possible_area_ids)
620  ]
621  if not candidates:
622  return MatchTargetsResult(
623  False, MatchFailedReason.FLOOR, floors=targeted_floors
624  )
625  else:
626  # All areas are possible
627  possible_area_ids = {area.id for area in ar.async_list_areas()}
628 
629  if constraints.area_name:
630  targeted_areas = list(find_areas(constraints.area_name, ar))
631  if not targeted_areas:
632  return MatchTargetsResult(
633  False,
634  MatchFailedReason.INVALID_AREA,
635  no_match_name=constraints.area_name,
636  )
637 
638  matching_area_ids = {area.id for area in targeted_areas}
639 
640  # May be constrained by floors above
641  possible_area_ids.intersection_update(matching_area_ids)
642  candidates = [
643  c
644  for c in candidates
645  if (c.area is not None) and (c.area.id in possible_area_ids)
646  ]
647  if not candidates:
648  return MatchTargetsResult(
649  False, MatchFailedReason.AREA, areas=targeted_areas
650  )
651 
652  if constraints.assistant:
653  # Check exposure
654  candidates = [c for c in candidates if c.is_exposed]
655  if not candidates:
656  return MatchTargetsResult(False, MatchFailedReason.ASSISTANT)
657 
658  if constraints.name and (not constraints.allow_duplicate_names):
659  # Check for duplicates
660  if not areas_added:
661  ar = area_registry.async_get(hass)
662  dr = device_registry.async_get(hass)
663  _add_areas(ar, dr, candidates)
664  areas_added = True
665 
666  sorted_candidates = sorted(
667  [c for c in candidates if c.matched_name],
668  key=lambda c: c.matched_name or "",
669  )
670  final_candidates: list[MatchTargetsCandidate] = []
671  for name, group in groupby(sorted_candidates, key=lambda c: c.matched_name):
672  group_candidates = list(group)
673  if len(group_candidates) < 2:
674  # No duplicates for name
675  final_candidates.extend(group_candidates)
676  continue
677 
678  # Try to disambiguate by preferences
679  if preferences.floor_id:
680  group_candidates = [
681  c
682  for c in group_candidates
683  if (c.area is not None)
684  and (c.area.floor_id == preferences.floor_id)
685  ]
686  if len(group_candidates) < 2:
687  # Disambiguated by floor
688  final_candidates.extend(group_candidates)
689  continue
690 
691  if preferences.area_id:
692  group_candidates = [
693  c
694  for c in group_candidates
695  if (c.area is not None) and (c.area.id == preferences.area_id)
696  ]
697  if len(group_candidates) < 2:
698  # Disambiguated by area
699  final_candidates.extend(group_candidates)
700  continue
701 
702  # Couldn't disambiguate duplicate names
703  return MatchTargetsResult(
704  False,
705  MatchFailedReason.DUPLICATE_NAME,
706  no_match_name=name,
707  areas=targeted_areas or [],
708  floors=targeted_floors or [],
709  )
710 
711  if not final_candidates:
712  return MatchTargetsResult(
713  False,
714  MatchFailedReason.NAME,
715  areas=targeted_areas or [],
716  floors=targeted_floors or [],
717  )
718 
719  candidates = final_candidates
720 
721  return MatchTargetsResult(
722  True,
723  None,
724  states=[c.state for c in candidates],
725  areas=targeted_areas or [],
726  floors=targeted_floors or [],
727  )
728 
729 
730 @callback
731 @bind_hass
733  hass: HomeAssistant,
734  name: str | None = None,
735  area_name: str | None = None,
736  floor_name: str | None = None,
737  domains: Collection[str] | None = None,
738  device_classes: Collection[str] | None = None,
739  states: list[State] | None = None,
740  assistant: str | None = None,
741 ) -> Iterable[State]:
742  """Simplified interface to async_match_targets that returns states matching the constraints."""
743  result = async_match_targets(
744  hass,
745  constraints=MatchTargetsConstraints(
746  name=name,
747  area_name=area_name,
748  floor_name=floor_name,
749  domains=domains,
750  device_classes=device_classes,
751  assistant=assistant,
752  ),
753  states=states,
754  )
755  return result.states
756 
757 
758 @callback
759 def async_test_feature(state: State, feature: int, feature_name: str) -> None:
760  """Test if state supports a feature."""
761  if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0:
762  raise IntentHandleError(f"Entity {state.name} does not support {feature_name}")
763 
764 
766  """Intent handler registration."""
767 
768  intent_type: str
769  platforms: set[str] | None = None
770  description: str | None = None
771 
772  @property
773  def slot_schema(self) -> dict | None:
774  """Return a slot schema."""
775  return None
776 
777  @callback
778  def async_can_handle(self, intent_obj: Intent) -> bool:
779  """Test if an intent can be handled."""
780  return self.platforms is None or intent_obj.platform in self.platforms
781 
782  @callback
783  def async_validate_slots(self, slots: _SlotsType) -> _SlotsType:
784  """Validate slot information."""
785  if self.slot_schemaslot_schema is None:
786  return slots
787 
788  return self._slot_schema_slot_schema(slots) # type: ignore[no-any-return]
789 
790  @cached_property
791  def _slot_schema(self) -> vol.Schema:
792  """Create validation schema for slots."""
793  assert self.slot_schemaslot_schema is not None
794  return vol.Schema(
795  {
796  key: SLOT_SCHEMA.extend({"value": validator})
797  for key, validator in self.slot_schemaslot_schema.items()
798  },
799  extra=vol.ALLOW_EXTRA,
800  )
801 
802  async def async_handle(self, intent_obj: Intent) -> IntentResponse:
803  """Handle the intent."""
804  raise NotImplementedError
805 
806  def __repr__(self) -> str:
807  """Represent a string of an intent handler."""
808  return f"<{self.__class__.__name__} - {self.intent_type}>"
809 
810 
811 def non_empty_string(value: Any) -> str:
812  """Coerce value to string and fail if string is empty or whitespace."""
813  value_str = cv.string(value)
814  if not value_str.strip():
815  raise vol.Invalid("string value is empty")
816 
817  return value_str
818 
819 
821  """Service Intent handler registration (dynamic).
822 
823  Service specific intent handler that calls a service by name/entity_id.
824  """
825 
826  # We use a small timeout in service calls to (hopefully) pass validation
827  # checks, but not try to wait for the call to fully complete.
828  service_timeout: float = 0.2
829 
830  def __init__(
831  self,
832  intent_type: str,
833  speech: str | None = None,
834  required_slots: _IntentSlotsType | None = None,
835  optional_slots: _IntentSlotsType | None = None,
836  required_domains: set[str] | None = None,
837  required_features: int | None = None,
838  required_states: set[str] | None = None,
839  description: str | None = None,
840  platforms: set[str] | None = None,
841  device_classes: set[type[StrEnum]] | None = None,
842  ) -> None:
843  """Create Service Intent Handler."""
844  self.intent_typeintent_type = intent_type
845  self.speechspeech = speech
846  self.required_domainsrequired_domains = required_domains
847  self.required_featuresrequired_features = required_features
848  self.required_statesrequired_states = required_states
849  self.descriptiondescription = description
850  self.platformsplatforms = platforms
851  self.device_classesdevice_classes = device_classes
852 
853  self.required_slots: _IntentSlotsType = {}
854  if required_slots:
855  for key, value_schema in required_slots.items():
856  if isinstance(key, str):
857  # Slot name/service data key
858  key = (key, key)
859 
860  self.required_slots[key] = value_schema
861 
862  self.optional_slots: _IntentSlotsType = {}
863  if optional_slots:
864  for key, value_schema in optional_slots.items():
865  if isinstance(key, str):
866  # Slot name/service data key
867  key = (key, key)
868 
869  self.optional_slots[key] = value_schema
870 
871  @cached_property
872  def slot_schema(self) -> dict:
873  """Return a slot schema."""
874  domain_validator = (
875  vol.In(list(self.required_domainsrequired_domains)) if self.required_domainsrequired_domains else cv.string
876  )
877  slot_schema = {
878  vol.Any("name", "area", "floor"): non_empty_string,
879  vol.Optional("domain"): vol.All(cv.ensure_list, [domain_validator]),
880  }
881  if self.device_classesdevice_classes:
882  # The typical way to match enums is with vol.Coerce, but we build a
883  # flat list to make the API simpler to describe programmatically
884  flattened_device_classes = vol.In(
885  [
886  device_class.value
887  for device_class_enum in self.device_classesdevice_classes
888  for device_class in device_class_enum
889  ]
890  )
891  slot_schema.update(
892  {
893  vol.Optional("device_class"): vol.All(
894  cv.ensure_list,
895  [flattened_device_classes],
896  )
897  }
898  )
899 
900  slot_schema.update(
901  {
902  vol.Optional("preferred_area_id"): cv.string,
903  vol.Optional("preferred_floor_id"): cv.string,
904  }
905  )
906 
907  if self.required_slots:
908  slot_schema.update(
909  {
910  vol.Required(key[0]): validator
911  for key, validator in self.required_slots.items()
912  }
913  )
914 
915  if self.optional_slots:
916  slot_schema.update(
917  {
918  vol.Optional(key[0]): validator
919  for key, validator in self.optional_slots.items()
920  }
921  )
922 
923  return slot_schema
924 
925  @abstractmethod
927  self, intent_obj: Intent, state: State
928  ) -> tuple[str, str]:
929  """Get the domain and service name to call."""
930  raise NotImplementedError
931 
932  async def async_handle(self, intent_obj: Intent) -> IntentResponse:
933  """Handle the hass intent."""
934  hass = intent_obj.hass
935  slots = self.async_validate_slotsasync_validate_slots(intent_obj.slots)
936 
937  name_slot = slots.get("name", {})
938  entity_name: str | None = name_slot.get("value")
939  entity_text: str | None = name_slot.get("text")
940  if entity_name == "all":
941  # Don't match on name if targeting all entities
942  entity_name = None
943 
944  # Get area/floor info
945  area_slot = slots.get("area", {})
946  area_id = area_slot.get("value")
947 
948  floor_slot = slots.get("floor", {})
949  floor_id = floor_slot.get("value")
950 
951  # Optional domain/device class filters.
952  # Convert to sets for speed.
953  domains: set[str] | None = self.required_domainsrequired_domains
954  device_classes: set[str] | None = None
955 
956  if "domain" in slots:
957  domains = set(slots["domain"]["value"])
958 
959  if "device_class" in slots:
960  device_classes = set(slots["device_class"]["value"])
961 
962  match_constraints = MatchTargetsConstraints(
963  name=entity_name,
964  area_name=area_id,
965  floor_name=floor_id,
966  domains=domains,
967  device_classes=device_classes,
968  assistant=intent_obj.assistant,
969  features=self.required_featuresrequired_features,
970  states=self.required_statesrequired_states,
971  )
972  if not match_constraints.has_constraints:
973  # Fail if attempting to target all devices in the house
974  raise IntentHandleError("Service handler cannot target all devices")
975 
976  match_preferences = MatchTargetsPreferences(
977  area_id=slots.get("preferred_area_id", {}).get("value"),
978  floor_id=slots.get("preferred_floor_id", {}).get("value"),
979  )
980 
981  match_result = async_match_targets(hass, match_constraints, match_preferences)
982  if not match_result.is_match:
983  raise MatchFailedError(
984  result=match_result,
985  constraints=match_constraints,
986  preferences=match_preferences,
987  )
988 
989  # Ensure name is text
990  if ("name" in slots) and entity_text:
991  slots["name"]["value"] = entity_text
992 
993  # Replace area/floor values with the resolved ids for use in templates
994  if ("area" in slots) and match_result.areas:
995  slots["area"]["value"] = match_result.areas[0].id
996 
997  if ("floor" in slots) and match_result.floors:
998  slots["floor"]["value"] = match_result.floors[0].floor_id
999 
1000  # Update intent slots to include any transformations done by the schemas
1001  intent_obj.slots = slots
1002 
1003  response = await self.async_handle_statesasync_handle_states(
1004  intent_obj, match_result, match_constraints, match_preferences
1005  )
1006 
1007  # Make the matched states available in the response
1008  response.async_set_states(
1009  matched_states=match_result.states, unmatched_states=[]
1010  )
1011 
1012  return response
1013 
1015  self,
1016  intent_obj: Intent,
1017  match_result: MatchTargetsResult,
1018  match_constraints: MatchTargetsConstraints,
1019  match_preferences: MatchTargetsPreferences | None = None,
1020  ) -> IntentResponse:
1021  """Complete action on matched entity states."""
1022  states = match_result.states
1023  response = intent_obj.create_response()
1024 
1025  hass = intent_obj.hass
1026  success_results: list[IntentResponseTarget] = []
1027 
1028  if match_result.floors:
1029  success_results.extend(
1031  type=IntentResponseTargetType.FLOOR,
1032  name=floor.name,
1033  id=floor.floor_id,
1034  )
1035  for floor in match_result.floors
1036  )
1037  speech_name = match_result.floors[0].name
1038  elif match_result.areas:
1039  success_results.extend(
1041  type=IntentResponseTargetType.AREA, name=area.name, id=area.id
1042  )
1043  for area in match_result.areas
1044  )
1045  speech_name = match_result.areas[0].name
1046  else:
1047  speech_name = states[0].name
1048 
1049  service_coros: list[Coroutine[Any, Any, None]] = []
1050  for state in states:
1051  domain, service = self.get_domain_and_serviceget_domain_and_service(intent_obj, state)
1052  service_coros.append(
1053  self.async_call_serviceasync_call_service(domain, service, intent_obj, state)
1054  )
1055 
1056  # Handle service calls in parallel, noting failures as they occur.
1057  failed_results: list[IntentResponseTarget] = []
1058  for state, service_coro in zip(
1059  states, asyncio.as_completed(service_coros), strict=False
1060  ):
1061  target = IntentResponseTarget(
1062  type=IntentResponseTargetType.ENTITY,
1063  name=state.name,
1064  id=state.entity_id,
1065  )
1066 
1067  try:
1068  await service_coro
1069  success_results.append(target)
1070  except Exception:
1071  failed_results.append(target)
1072  _LOGGER.exception("Service call failed for %s", state.entity_id)
1073 
1074  if not success_results:
1075  # If no entities succeeded, raise an error.
1076  failed_entity_ids = [target.id for target in failed_results]
1077  raise IntentHandleError(
1078  f"Failed to call {service} for: {failed_entity_ids}"
1079  )
1080 
1081  response.async_set_results(
1082  success_results=success_results, failed_results=failed_results
1083  )
1084 
1085  # Update all states
1086  states = [hass.states.get(state.entity_id) or state for state in states]
1087  response.async_set_states(states)
1088 
1089  if self.speechspeech is not None:
1090  response.async_set_speech(self.speechspeech.format(speech_name))
1091 
1092  return response
1093 
1095  self, domain: str, service: str, intent_obj: Intent, state: State
1096  ) -> None:
1097  """Call service on entity."""
1098  hass = intent_obj.hass
1099 
1100  service_data: dict[str, Any] = {ATTR_ENTITY_ID: state.entity_id}
1101  if self.required_slots:
1102  service_data.update(
1103  {
1104  key[1]: intent_obj.slots[key[0]]["value"]
1105  for key in self.required_slots
1106  }
1107  )
1108 
1109  if self.optional_slots:
1110  for key in self.optional_slots:
1111  value = intent_obj.slots.get(key[0])
1112  if value:
1113  service_data[key[1]] = value["value"]
1114 
1115  await self._run_then_background_run_then_background(
1116  hass.async_create_task_internal(
1117  hass.services.async_call(
1118  domain,
1119  service,
1120  service_data,
1121  context=intent_obj.context,
1122  blocking=True,
1123  ),
1124  f"intent_call_service_{domain}_{service}",
1125  )
1126  )
1127 
1128  async def _run_then_background(self, task: asyncio.Task[Any]) -> None:
1129  """Run task with timeout to (hopefully) catch validation errors.
1130 
1131  After the timeout the task will continue to run in the background.
1132  """
1133  try:
1134  await asyncio.wait({task}, timeout=self.service_timeout)
1135  except TimeoutError:
1136  pass
1137  except asyncio.CancelledError:
1138  # Task calling us was cancelled, so cancel service call task, and wait for
1139  # it to be cancelled, within reason, before leaving.
1140  _LOGGER.debug("Service call was cancelled: %s", task.get_name())
1141  task.cancel()
1142  await asyncio.wait({task}, timeout=5)
1143  raise
1144 
1145 
1147  """Service Intent handler registration.
1148 
1149  Service specific intent handler that calls a service by name/entity_id.
1150  """
1151 
1153  self,
1154  intent_type: str,
1155  domain: str,
1156  service: str,
1157  speech: str | None = None,
1158  required_slots: _IntentSlotsType | None = None,
1159  optional_slots: _IntentSlotsType | None = None,
1160  required_domains: set[str] | None = None,
1161  required_features: int | None = None,
1162  required_states: set[str] | None = None,
1163  description: str | None = None,
1164  platforms: set[str] | None = None,
1165  device_classes: set[type[StrEnum]] | None = None,
1166  ) -> None:
1167  """Create service handler."""
1168  super().__init__(
1169  intent_type,
1170  speech=speech,
1171  required_slots=required_slots,
1172  optional_slots=optional_slots,
1173  required_domains=required_domains,
1174  required_features=required_features,
1175  required_states=required_states,
1176  description=description,
1177  platforms=platforms,
1178  device_classes=device_classes,
1179  )
1180  self.domaindomain = domain
1181  self.serviceservice = service
1182 
1184  self, intent_obj: Intent, state: State
1185  ) -> tuple[str, str]:
1186  """Get the domain and service name to call."""
1187  return (self.domaindomain, self.serviceservice)
1188 
1189 
1190 class IntentCategory(Enum):
1191  """Category of an intent."""
1192 
1193  ACTION = "action"
1194  """Trigger an action like turning an entity on or off"""
1195 
1196  QUERY = "query"
1197  """Get information about the state of an entity"""
1198 
1199 
1200 class Intent:
1201  """Hold the intent."""
1202 
1203  __slots__ = [
1204  "hass",
1205  "platform",
1206  "intent_type",
1207  "slots",
1208  "text_input",
1209  "context",
1210  "language",
1211  "category",
1212  "assistant",
1213  "device_id",
1214  "conversation_agent_id",
1215  ]
1216 
1218  self,
1219  hass: HomeAssistant,
1220  platform: str,
1221  intent_type: str,
1222  slots: _SlotsType,
1223  text_input: str | None,
1224  context: Context,
1225  language: str,
1226  category: IntentCategory | None = None,
1227  assistant: str | None = None,
1228  device_id: str | None = None,
1229  conversation_agent_id: str | None = None,
1230  ) -> None:
1231  """Initialize an intent."""
1232  self.hasshass = hass
1233  self.platformplatform = platform
1234  self.intent_typeintent_type = intent_type
1235  self.slotsslots = slots
1236  self.text_inputtext_input = text_input
1237  self.contextcontext = context
1238  self.languagelanguage = language
1239  self.categorycategory = category
1240  self.assistantassistant = assistant
1241  self.device_iddevice_id = device_id
1242  self.conversation_agent_idconversation_agent_id = conversation_agent_id
1243 
1244  @callback
1245  def create_response(self) -> IntentResponse:
1246  """Create a response."""
1247  return IntentResponse(language=self.languagelanguage, intent=self)
1248 
1249 
1251  """Type of the intent response."""
1252 
1253  ACTION_DONE = "action_done"
1254  """Intent caused an action to occur"""
1255 
1256  PARTIAL_ACTION_DONE = "partial_action_done"
1257  """Intent caused an action, but it could only be partially done"""
1258 
1259  QUERY_ANSWER = "query_answer"
1260  """Response is an answer to a query"""
1261 
1262  ERROR = "error"
1263  """Response is an error"""
1264 
1265 
1267  """Reason for an intent response error."""
1268 
1269  NO_INTENT_MATCH = "no_intent_match"
1270  """Text could not be matched to an intent"""
1271 
1272  NO_VALID_TARGETS = "no_valid_targets"
1273  """Intent was matched, but no valid areas/devices/entities were targeted"""
1274 
1275  FAILED_TO_HANDLE = "failed_to_handle"
1276  """Unexpected error occurred while handling intent"""
1277 
1278  UNKNOWN = "unknown"
1279  """Error outside the scope of intent processing"""
1280 
1281 
1283  """Type of target for an intent response."""
1284 
1285  AREA = "area"
1286  FLOOR = "floor"
1287  DEVICE = "device"
1288  ENTITY = "entity"
1289  DOMAIN = "domain"
1290  DEVICE_CLASS = "device_class"
1291  CUSTOM = "custom"
1292 
1293 
1294 @dataclass(slots=True)
1296  """Target of the intent response."""
1297 
1298  name: str
1299  type: IntentResponseTargetType
1300  id: str | None = None
1301 
1302 
1304  """Response to an intent."""
1305 
1307  self,
1308  language: str,
1309  intent: Intent | None = None,
1310  ) -> None:
1311  """Initialize an IntentResponse."""
1312  self.languagelanguage = language
1313  self.intentintent = intent
1314  self.speech: dict[str, dict[str, Any]] = {}
1315  self.reprompt: dict[str, dict[str, Any]] = {}
1316  self.card: dict[str, dict[str, str]] = {}
1317  self.error_codeerror_code: IntentResponseErrorCode | None = None
1318  self.intent_targetsintent_targets: list[IntentResponseTarget] = []
1319  self.success_resultssuccess_results: list[IntentResponseTarget] = []
1320  self.failed_resultsfailed_results: list[IntentResponseTarget] = []
1321  self.matched_statesmatched_states: list[State] = []
1322  self.unmatched_statesunmatched_states: list[State] = []
1323  self.speech_slotsspeech_slots: dict[str, Any] = {}
1324 
1325  if (self.intentintent is not None) and (self.intentintent.category == IntentCategory.QUERY):
1326  # speech will be the answer to the query
1327  self.response_typeresponse_type = IntentResponseType.QUERY_ANSWER
1328  else:
1329  self.response_typeresponse_type = IntentResponseType.ACTION_DONE
1330 
1331  @callback
1333  self,
1334  speech: str,
1335  speech_type: str = "plain",
1336  extra_data: Any | None = None,
1337  ) -> None:
1338  """Set speech response."""
1339  self.speech[speech_type] = {
1340  "speech": speech,
1341  "extra_data": extra_data,
1342  }
1343 
1344  @callback
1346  self,
1347  speech: str,
1348  speech_type: str = "plain",
1349  extra_data: Any | None = None,
1350  ) -> None:
1351  """Set reprompt response."""
1352  self.reprompt[speech_type] = {
1353  "reprompt": speech,
1354  "extra_data": extra_data,
1355  }
1356 
1357  @callback
1359  self, title: str, content: str, card_type: str = "simple"
1360  ) -> None:
1361  """Set card response."""
1362  self.card[card_type] = {"title": title, "content": content}
1363 
1364  @callback
1365  def async_set_error(self, code: IntentResponseErrorCode, message: str) -> None:
1366  """Set response error."""
1367  self.response_typeresponse_type = IntentResponseType.ERROR
1368  self.error_codeerror_code = code
1369 
1370  # Speak error message
1371  self.async_set_speechasync_set_speech(message)
1372 
1373  @callback
1375  self,
1376  intent_targets: list[IntentResponseTarget],
1377  ) -> None:
1378  """Set response targets."""
1379  self.intent_targetsintent_targets = intent_targets
1380 
1381  @callback
1383  self,
1384  success_results: list[IntentResponseTarget],
1385  failed_results: list[IntentResponseTarget] | None = None,
1386  ) -> None:
1387  """Set response results."""
1388  self.success_resultssuccess_results = success_results
1389  self.failed_resultsfailed_results = failed_results if failed_results is not None else []
1390 
1391  @callback
1393  self, matched_states: list[State], unmatched_states: list[State] | None = None
1394  ) -> None:
1395  """Set entity states that were matched or not matched during intent handling (query)."""
1396  self.matched_statesmatched_states = matched_states
1397  self.unmatched_statesunmatched_states = unmatched_states or []
1398 
1399  @callback
1400  def async_set_speech_slots(self, speech_slots: dict[str, Any]) -> None:
1401  """Set slots that will be used in the response template of the default agent."""
1402  self.speech_slotsspeech_slots = speech_slots
1403 
1404  @callback
1405  def as_dict(self) -> dict[str, Any]:
1406  """Return a dictionary representation of an intent response."""
1407  response_dict: dict[str, Any] = {
1408  "speech": self.speech,
1409  "card": self.card,
1410  "language": self.languagelanguage,
1411  "response_type": self.response_typeresponse_type.value,
1412  }
1413 
1414  if self.reprompt:
1415  response_dict["reprompt"] = self.reprompt
1416  if self.speech_slotsspeech_slots:
1417  response_dict["speech_slots"] = self.speech_slotsspeech_slots
1418 
1419  response_data: dict[str, Any] = {}
1420 
1421  if self.response_typeresponse_type == IntentResponseType.ERROR:
1422  assert self.error_codeerror_code is not None, "error code is required"
1423  response_data["code"] = self.error_codeerror_code.value
1424  else:
1425  # action done or query answer
1426  response_data["targets"] = [
1427  dataclasses.asdict(target) for target in self.intent_targetsintent_targets
1428  ]
1429 
1430  # Add success/failed targets
1431  response_data["success"] = [
1432  dataclasses.asdict(target) for target in self.success_resultssuccess_results
1433  ]
1434 
1435  response_data["failed"] = [
1436  dataclasses.asdict(target) for target in self.failed_resultsfailed_results
1437  ]
1438 
1439  response_dict["data"] = response_data
1440 
1441  return response_dict
tuple[str, str] get_domain_and_service(self, Intent intent_obj, State state)
Definition: intent.py:928
None __init__(self, str intent_type, str|None speech=None, _IntentSlotsType|None required_slots=None, _IntentSlotsType|None optional_slots=None, set[str]|None required_domains=None, int|None required_features=None, set[str]|None required_states=None, str|None description=None, set[str]|None platforms=None, set[type[StrEnum]]|None device_classes=None)
Definition: intent.py:842
None _run_then_background(self, asyncio.Task[Any] task)
Definition: intent.py:1128
IntentResponse async_handle_states(self, Intent intent_obj, MatchTargetsResult match_result, MatchTargetsConstraints match_constraints, MatchTargetsPreferences|None match_preferences=None)
Definition: intent.py:1020
None async_call_service(self, str domain, str service, Intent intent_obj, State state)
Definition: intent.py:1096
IntentResponse async_handle(self, Intent intent_obj)
Definition: intent.py:932
None __init__(self, str message="", str|None response_key=None)
Definition: intent.py:171
IntentResponse async_handle(self, Intent intent_obj)
Definition: intent.py:802
_SlotsType async_validate_slots(self, _SlotsType slots)
Definition: intent.py:783
bool async_can_handle(self, Intent intent_obj)
Definition: intent.py:778
None async_set_card(self, str title, str content, str card_type="simple")
Definition: intent.py:1360
None async_set_states(self, list[State] matched_states, list[State]|None unmatched_states=None)
Definition: intent.py:1394
None async_set_reprompt(self, str speech, str speech_type="plain", Any|None extra_data=None)
Definition: intent.py:1350
None async_set_speech(self, str speech, str speech_type="plain", Any|None extra_data=None)
Definition: intent.py:1337
None async_set_results(self, list[IntentResponseTarget] success_results, list[IntentResponseTarget]|None failed_results=None)
Definition: intent.py:1386
None async_set_error(self, IntentResponseErrorCode code, str message)
Definition: intent.py:1365
None async_set_targets(self, list[IntentResponseTarget] intent_targets)
Definition: intent.py:1377
None async_set_speech_slots(self, dict[str, Any] speech_slots)
Definition: intent.py:1400
None __init__(self, str language, Intent|None intent=None)
Definition: intent.py:1310
IntentResponse create_response(self)
Definition: intent.py:1245
None __init__(self, HomeAssistant hass, str platform, str intent_type, _SlotsType slots, str|None text_input, Context context, str language, IntentCategory|None category=None, str|None assistant=None, str|None device_id=None, str|None conversation_agent_id=None)
Definition: intent.py:1230
None __init__(self, MatchTargetsResult result, MatchTargetsConstraints constraints, MatchTargetsPreferences|None preferences=None)
Definition: intent.py:313
None __init__(self, MatchFailedReason reason, str|None name=None, str|None area=None, str|None floor=None, set[str]|None domains=None, set[str]|None device_classes=None)
Definition: intent.py:337
tuple[str, str] get_domain_and_service(self, Intent intent_obj, State state)
Definition: intent.py:1185
None __init__(self, str intent_type, str domain, str service, str|None speech=None, _IntentSlotsType|None required_slots=None, _IntentSlotsType|None optional_slots=None, set[str]|None required_domains=None, int|None required_features=None, set[str]|None required_states=None, str|None description=None, set[str]|None platforms=None, set[type[StrEnum]]|None device_classes=None)
Definition: intent.py:1166
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
bool async_should_expose(HomeAssistant hass, str assistant, str entity_id)
Iterable[MatchTargetsCandidate] _filter_by_name(str name, Iterable[MatchTargetsCandidate] candidates)
Definition: intent.py:412
Iterable[MatchTargetsCandidate] _filter_by_device_classes(Iterable[str] device_classes, Iterable[MatchTargetsCandidate] candidates)
Definition: intent.py:464
None _add_areas(area_registry.AreaRegistry areas, device_registry.DeviceRegistry devices, Iterable[MatchTargetsCandidate] candidates)
Definition: intent.py:484
Iterable[floor_registry.FloorEntry] find_floors(str name, floor_registry.FloorRegistry floors)
Definition: intent.py:386
Iterable[State] async_match_states(HomeAssistant hass, str|None name=None, str|None area_name=None, str|None floor_name=None, Collection[str]|None domains=None, Collection[str]|None device_classes=None, list[State]|None states=None, str|None assistant=None)
Definition: intent.py:741
None async_test_feature(State state, int feature, str feature_name)
Definition: intent.py:759
MatchTargetsResult async_match_targets(HomeAssistant hass, MatchTargetsConstraints constraints, MatchTargetsPreferences|None preferences=None, list[State]|None states=None)
Definition: intent.py:508
None async_register(HomeAssistant hass, IntentHandler handler)
Definition: intent.py:72
Iterable[area_registry.AreaEntry] find_areas(str name, area_registry.AreaRegistry areas)
Definition: intent.py:366
str non_empty_string(Any value)
Definition: intent.py:811
IntentResponse async_handle(HomeAssistant hass, str platform, str intent_type, _SlotsType|None slots=None, str|None text_input=None, Context|None context=None, str|None language=None, str|None assistant=None, str|None device_id=None, str|None conversation_agent_id=None)
Definition: intent.py:116
None async_remove(HomeAssistant hass, str intent_type)
Definition: intent.py:90
Iterable[IntentHandler] async_get(HomeAssistant hass)
Definition: intent.py:99
str _normalize_name(str name)
Definition: intent.py:404
Iterable[MatchTargetsCandidate] _filter_by_features(int features, Iterable[MatchTargetsCandidate] candidates)
Definition: intent.py:447