2017-07-22 04:38:53 +00:00
|
|
|
"""Module to coordinate user intentions."""
|
2024-02-03 11:14:33 +00:00
|
|
|
|
2021-02-12 09:58:20 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
import asyncio
|
2024-01-08 09:09:48 +00:00
|
|
|
from collections.abc import Collection, Coroutine, Iterable
|
2022-12-09 01:30:08 +00:00
|
|
|
import dataclasses
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from enum import Enum
|
2024-02-20 03:28:42 +00:00
|
|
|
from functools import cached_property
|
2017-07-22 04:38:53 +00:00
|
|
|
import logging
|
2022-03-15 08:30:55 +00:00
|
|
|
from typing import Any, TypeVar
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
import voluptuous as vol
|
|
|
|
|
2023-05-03 16:18:31 +00:00
|
|
|
from homeassistant.components.homeassistant.exposed_entities import async_should_expose
|
2023-01-19 23:15:01 +00:00
|
|
|
from homeassistant.const import (
|
|
|
|
ATTR_DEVICE_CLASS,
|
|
|
|
ATTR_ENTITY_ID,
|
|
|
|
ATTR_SUPPORTED_FEATURES,
|
|
|
|
)
|
2022-03-15 08:30:55 +00:00
|
|
|
from homeassistant.core import Context, HomeAssistant, State, callback
|
2017-07-22 04:38:53 +00:00
|
|
|
from homeassistant.exceptions import HomeAssistantError
|
2017-10-08 15:17:54 +00:00
|
|
|
from homeassistant.loader import bind_hass
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2023-01-26 15:48:49 +00:00
|
|
|
from . import area_registry, config_validation as cv, device_registry, entity_registry
|
2021-12-23 19:14:47 +00:00
|
|
|
|
2017-07-22 04:38:53 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2022-01-11 20:26:03 +00:00
|
|
|
_SlotsType = dict[str, Any]
|
2022-03-15 08:30:55 +00:00
|
|
|
_T = TypeVar("_T")
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
INTENT_TURN_OFF = "HassTurnOff"
|
|
|
|
INTENT_TURN_ON = "HassTurnOn"
|
|
|
|
INTENT_TOGGLE = "HassToggle"
|
2023-02-10 04:39:46 +00:00
|
|
|
INTENT_GET_STATE = "HassGetState"
|
2023-10-17 00:13:26 +00:00
|
|
|
INTENT_NEVERMIND = "HassNevermind"
|
2024-02-20 03:28:42 +00:00
|
|
|
INTENT_SET_POSITION = "HassSetPosition"
|
2018-02-11 17:33:19 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA)
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
DATA_KEY = "intent"
|
2018-02-11 17:33:19 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
SPEECH_TYPE_PLAIN = "plain"
|
|
|
|
SPEECH_TYPE_SSML = "ssml"
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
|
|
|
|
@callback
|
2017-10-08 15:17:54 +00:00
|
|
|
@bind_hass
|
2021-03-27 11:55:24 +00:00
|
|
|
def async_register(hass: HomeAssistant, handler: IntentHandler) -> None:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Register an intent with Home Assistant."""
|
2021-10-17 18:08:11 +00:00
|
|
|
if (intents := hass.data.get(DATA_KEY)) is None:
|
2017-07-22 04:38:53 +00:00
|
|
|
intents = hass.data[DATA_KEY] = {}
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
assert handler.intent_type is not None, "intent_type cannot be None"
|
2018-02-28 02:02:21 +00:00
|
|
|
|
2017-07-22 04:38:53 +00:00
|
|
|
if handler.intent_type in intents:
|
2019-07-31 19:25:30 +00:00
|
|
|
_LOGGER.warning(
|
2020-07-05 21:04:19 +00:00
|
|
|
"Intent %s is being overwritten by %s", handler.intent_type, handler
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
intents[handler.intent_type] = handler
|
|
|
|
|
|
|
|
|
2023-06-03 18:02:23 +00:00
|
|
|
@callback
|
|
|
|
@bind_hass
|
|
|
|
def async_remove(hass: HomeAssistant, intent_type: str) -> None:
|
|
|
|
"""Remove an intent from Home Assistant."""
|
|
|
|
if (intents := hass.data.get(DATA_KEY)) is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
intents.pop(intent_type, None)
|
|
|
|
|
|
|
|
|
2017-10-08 15:17:54 +00:00
|
|
|
@bind_hass
|
2019-07-31 19:25:30 +00:00
|
|
|
async def async_handle(
|
2021-03-27 11:55:24 +00:00
|
|
|
hass: HomeAssistant,
|
2019-07-31 19:25:30 +00:00
|
|
|
platform: str,
|
|
|
|
intent_type: str,
|
2021-03-17 17:34:19 +00:00
|
|
|
slots: _SlotsType | None = None,
|
|
|
|
text_input: str | None = None,
|
|
|
|
context: Context | None = None,
|
2022-12-08 16:39:28 +00:00
|
|
|
language: str | None = None,
|
2023-05-03 16:18:31 +00:00
|
|
|
assistant: str | None = None,
|
2021-02-12 09:58:20 +00:00
|
|
|
) -> IntentResponse:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Handle an intent."""
|
2019-09-04 03:36:04 +00:00
|
|
|
handler: IntentHandler = hass.data.get(DATA_KEY, {}).get(intent_type)
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
if handler is None:
|
2019-08-23 16:53:33 +00:00
|
|
|
raise UnknownIntent(f"Unknown intent {intent_type}")
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2019-11-26 10:30:21 +00:00
|
|
|
if context is None:
|
|
|
|
context = Context()
|
|
|
|
|
2022-12-08 16:39:28 +00:00
|
|
|
if language is None:
|
|
|
|
language = hass.config.language
|
|
|
|
|
|
|
|
intent = Intent(
|
2023-05-03 16:18:31 +00:00
|
|
|
hass,
|
|
|
|
platform=platform,
|
|
|
|
intent_type=intent_type,
|
|
|
|
slots=slots or {},
|
|
|
|
text_input=text_input,
|
|
|
|
context=context,
|
|
|
|
language=language,
|
|
|
|
assistant=assistant,
|
2022-12-08 16:39:28 +00:00
|
|
|
)
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
try:
|
|
|
|
_LOGGER.info("Triggering intent handler %s", handler)
|
2018-02-25 11:38:46 +00:00
|
|
|
result = await handler.async_handle(intent)
|
2017-07-22 04:38:53 +00:00
|
|
|
return result
|
|
|
|
except vol.Invalid as err:
|
2019-07-31 19:25:30 +00:00
|
|
|
_LOGGER.warning("Received invalid slot info for %s: %s", intent_type, err)
|
2019-08-23 16:53:33 +00:00
|
|
|
raise InvalidSlotInfo(f"Received invalid slot info for {intent_type}") from err
|
2024-01-08 18:23:06 +00:00
|
|
|
except IntentError:
|
|
|
|
raise # bubble up intent related errors
|
2017-07-22 04:38:53 +00:00
|
|
|
except Exception as err:
|
2019-08-23 16:53:33 +00:00
|
|
|
raise IntentUnexpectedError(f"Error handling {intent_type}") from err
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
|
|
|
|
class IntentError(HomeAssistantError):
|
|
|
|
"""Base class for intent related errors."""
|
|
|
|
|
|
|
|
|
|
|
|
class UnknownIntent(IntentError):
|
|
|
|
"""When the intent is not registered."""
|
|
|
|
|
|
|
|
|
|
|
|
class InvalidSlotInfo(IntentError):
|
|
|
|
"""When the slot data is invalid."""
|
|
|
|
|
|
|
|
|
|
|
|
class IntentHandleError(IntentError):
|
|
|
|
"""Error while handling intent."""
|
|
|
|
|
2018-02-28 02:02:21 +00:00
|
|
|
|
|
|
|
class IntentUnexpectedError(IntentError):
|
|
|
|
"""Unexpected error while handling intent."""
|
|
|
|
|
|
|
|
|
2024-01-08 18:23:06 +00:00
|
|
|
class NoStatesMatchedError(IntentError):
|
|
|
|
"""Error when no states match the intent's constraints."""
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
name: str | None,
|
|
|
|
area: str | None,
|
|
|
|
domains: set[str] | None,
|
|
|
|
device_classes: set[str] | None,
|
|
|
|
) -> None:
|
|
|
|
"""Initialize error."""
|
|
|
|
super().__init__()
|
|
|
|
|
|
|
|
self.name = name
|
|
|
|
self.area = area
|
|
|
|
self.domains = domains
|
|
|
|
self.device_classes = device_classes
|
|
|
|
|
|
|
|
|
2024-02-09 01:38:03 +00:00
|
|
|
class DuplicateNamesMatchedError(IntentError):
|
|
|
|
"""Error when two or more entities with the same name matched."""
|
|
|
|
|
|
|
|
def __init__(self, name: str, area: str | None) -> None:
|
|
|
|
"""Initialize error."""
|
|
|
|
super().__init__()
|
|
|
|
|
|
|
|
self.name = name
|
|
|
|
self.area = area
|
|
|
|
|
|
|
|
|
2023-01-19 23:15:01 +00:00
|
|
|
def _is_device_class(
|
|
|
|
state: State,
|
|
|
|
entity: entity_registry.RegistryEntry | None,
|
|
|
|
device_classes: Collection[str],
|
|
|
|
) -> bool:
|
|
|
|
"""Return true if entity device class matches."""
|
|
|
|
# Try entity first
|
|
|
|
if (entity is not None) and (entity.device_class is not None):
|
|
|
|
# Entity device class can be None or blank as "unset"
|
|
|
|
if entity.device_class in device_classes:
|
|
|
|
return True
|
2018-02-28 02:02:21 +00:00
|
|
|
|
2023-01-19 23:15:01 +00:00
|
|
|
# Fall back to state attribute
|
|
|
|
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
|
|
|
return (device_class is not None) and (device_class in device_classes)
|
2023-01-07 21:20:21 +00:00
|
|
|
|
|
|
|
|
2023-01-19 23:15:01 +00:00
|
|
|
def _has_name(
|
|
|
|
state: State, entity: entity_registry.RegistryEntry | None, name: str
|
|
|
|
) -> bool:
|
|
|
|
"""Return true if entity name or alias matches."""
|
|
|
|
if name in (state.entity_id, state.name.casefold()):
|
|
|
|
return True
|
2018-02-28 02:02:21 +00:00
|
|
|
|
2023-01-31 04:46:25 +00:00
|
|
|
# Check name/aliases
|
|
|
|
if (entity is None) or (not entity.aliases):
|
|
|
|
return False
|
|
|
|
|
|
|
|
for alias in entity.aliases:
|
|
|
|
if name == alias.casefold():
|
|
|
|
return True
|
2018-02-28 02:02:21 +00:00
|
|
|
|
2023-01-19 23:15:01 +00:00
|
|
|
return False
|
2018-02-28 02:02:21 +00:00
|
|
|
|
|
|
|
|
2023-01-31 04:46:25 +00:00
|
|
|
def _find_area(
|
|
|
|
id_or_name: str, areas: area_registry.AreaRegistry
|
|
|
|
) -> area_registry.AreaEntry | None:
|
|
|
|
"""Find an area by id or name, checking aliases too."""
|
|
|
|
area = areas.async_get_area(id_or_name) or areas.async_get_area_by_name(id_or_name)
|
|
|
|
if area is not None:
|
|
|
|
return area
|
|
|
|
|
|
|
|
# Check area aliases
|
|
|
|
for maybe_area in areas.areas.values():
|
|
|
|
if not maybe_area.aliases:
|
|
|
|
continue
|
|
|
|
|
|
|
|
for area_alias in maybe_area.aliases:
|
|
|
|
if id_or_name == area_alias.casefold():
|
|
|
|
return maybe_area
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _filter_by_area(
|
|
|
|
states_and_entities: list[tuple[State, entity_registry.RegistryEntry | None]],
|
|
|
|
area: area_registry.AreaEntry,
|
|
|
|
devices: device_registry.DeviceRegistry,
|
|
|
|
) -> Iterable[tuple[State, entity_registry.RegistryEntry | None]]:
|
|
|
|
"""Filter state/entity pairs by an area."""
|
|
|
|
entity_area_ids: dict[str, str | None] = {}
|
|
|
|
for _state, entity in states_and_entities:
|
|
|
|
if entity is None:
|
|
|
|
continue
|
|
|
|
|
|
|
|
if entity.area_id:
|
|
|
|
# Use entity's area id first
|
|
|
|
entity_area_ids[entity.id] = entity.area_id
|
|
|
|
elif entity.device_id:
|
|
|
|
# Fall back to device area if not set on entity
|
|
|
|
device = devices.async_get(entity.device_id)
|
|
|
|
if device is not None:
|
|
|
|
entity_area_ids[entity.id] = device.area_id
|
|
|
|
|
|
|
|
for state, entity in states_and_entities:
|
|
|
|
if (entity is not None) and (entity_area_ids.get(entity.id) == area.id):
|
|
|
|
yield (state, entity)
|
|
|
|
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
@callback
|
|
|
|
@bind_hass
|
2023-01-19 23:15:01 +00:00
|
|
|
def async_match_states(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
name: str | None = None,
|
|
|
|
area_name: str | None = None,
|
|
|
|
area: area_registry.AreaEntry | None = None,
|
|
|
|
domains: Collection[str] | None = None,
|
|
|
|
device_classes: Collection[str] | None = None,
|
|
|
|
states: Iterable[State] | None = None,
|
|
|
|
entities: entity_registry.EntityRegistry | None = None,
|
|
|
|
areas: area_registry.AreaRegistry | None = None,
|
2023-01-26 15:48:49 +00:00
|
|
|
devices: device_registry.DeviceRegistry | None = None,
|
2023-05-03 16:18:31 +00:00
|
|
|
assistant: str | None = None,
|
2023-01-19 23:15:01 +00:00
|
|
|
) -> Iterable[State]:
|
|
|
|
"""Find states that match the constraints."""
|
|
|
|
if states is None:
|
|
|
|
# All states
|
|
|
|
states = hass.states.async_all()
|
|
|
|
|
|
|
|
if entities is None:
|
|
|
|
entities = entity_registry.async_get(hass)
|
|
|
|
|
|
|
|
# Gather entities
|
|
|
|
states_and_entities: list[tuple[State, entity_registry.RegistryEntry | None]] = []
|
|
|
|
for state in states:
|
|
|
|
entity = entities.async_get(state.entity_id)
|
|
|
|
if (entity is not None) and entity.entity_category:
|
|
|
|
# Skip diagnostic entities
|
|
|
|
continue
|
|
|
|
|
|
|
|
states_and_entities.append((state, entity))
|
|
|
|
|
|
|
|
# Filter by domain and device class
|
|
|
|
if domains:
|
|
|
|
states_and_entities = [
|
|
|
|
(state, entity)
|
|
|
|
for state, entity in states_and_entities
|
|
|
|
if state.domain in domains
|
|
|
|
]
|
|
|
|
|
|
|
|
if device_classes:
|
|
|
|
# Check device class in state attribute and in entity entry (if available)
|
|
|
|
states_and_entities = [
|
|
|
|
(state, entity)
|
|
|
|
for state, entity in states_and_entities
|
|
|
|
if _is_device_class(state, entity, device_classes)
|
|
|
|
]
|
|
|
|
|
|
|
|
if (area is None) and (area_name is not None):
|
|
|
|
# Look up area by name
|
|
|
|
if areas is None:
|
|
|
|
areas = area_registry.async_get(hass)
|
|
|
|
|
2023-01-31 04:46:25 +00:00
|
|
|
area = _find_area(area_name, areas)
|
2023-01-19 23:15:01 +00:00
|
|
|
assert area is not None, f"No area named {area_name}"
|
|
|
|
|
|
|
|
if area is not None:
|
2023-01-31 04:46:25 +00:00
|
|
|
# Filter by states/entities by area
|
2023-01-26 15:48:49 +00:00
|
|
|
if devices is None:
|
|
|
|
devices = device_registry.async_get(hass)
|
|
|
|
|
2023-01-31 04:46:25 +00:00
|
|
|
states_and_entities = list(_filter_by_area(states_and_entities, area, devices))
|
2023-01-19 23:15:01 +00:00
|
|
|
|
2023-05-03 16:18:31 +00:00
|
|
|
if assistant is not None:
|
|
|
|
# Filter by exposure
|
|
|
|
states_and_entities = [
|
|
|
|
(state, entity)
|
|
|
|
for state, entity in states_and_entities
|
|
|
|
if async_should_expose(hass, assistant, state.entity_id)
|
|
|
|
]
|
|
|
|
|
2023-01-19 23:15:01 +00:00
|
|
|
if name is not None:
|
2023-01-31 04:46:25 +00:00
|
|
|
if devices is None:
|
|
|
|
devices = device_registry.async_get(hass)
|
|
|
|
|
2023-01-19 23:15:01 +00:00
|
|
|
# Filter by name
|
|
|
|
name = name.casefold()
|
|
|
|
|
2023-01-31 04:46:25 +00:00
|
|
|
# Check states
|
2023-01-19 23:15:01 +00:00
|
|
|
for state, entity in states_and_entities:
|
|
|
|
if _has_name(state, entity, name):
|
|
|
|
yield state
|
|
|
|
else:
|
|
|
|
# Not filtered by name
|
|
|
|
for state, _entity in states_and_entities:
|
|
|
|
yield state
|
2023-01-07 21:20:21 +00:00
|
|
|
|
|
|
|
|
2018-02-28 02:02:21 +00:00
|
|
|
@callback
|
2018-10-28 19:12:52 +00:00
|
|
|
def async_test_feature(state: State, feature: int, feature_name: str) -> None:
|
2021-05-14 21:23:29 +00:00
|
|
|
"""Test if state supports a feature."""
|
2018-02-28 02:02:21 +00:00
|
|
|
if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0:
|
2019-08-23 16:53:33 +00:00
|
|
|
raise IntentHandleError(f"Entity {state.name} does not support {feature_name}")
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
|
|
|
|
class IntentHandler:
|
|
|
|
"""Intent handler registration."""
|
|
|
|
|
2021-03-17 17:34:19 +00:00
|
|
|
intent_type: str | None = None
|
|
|
|
slot_schema: vol.Schema | None = None
|
|
|
|
platforms: Iterable[str] | None = []
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
@callback
|
2021-02-12 09:58:20 +00:00
|
|
|
def async_can_handle(self, intent_obj: Intent) -> bool:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Test if an intent can be handled."""
|
|
|
|
return self.platforms is None or intent_obj.platform in self.platforms
|
|
|
|
|
|
|
|
@callback
|
2018-10-28 19:12:52 +00:00
|
|
|
def async_validate_slots(self, slots: _SlotsType) -> _SlotsType:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Validate slot information."""
|
|
|
|
if self.slot_schema is None:
|
|
|
|
return slots
|
|
|
|
|
2022-02-18 10:31:37 +00:00
|
|
|
return self._slot_schema(slots) # type: ignore[no-any-return]
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2024-02-20 03:28:42 +00:00
|
|
|
@cached_property
|
|
|
|
def _slot_schema(self) -> vol.Schema:
|
|
|
|
"""Create validation schema for slots."""
|
|
|
|
assert self.slot_schema is not None
|
|
|
|
return vol.Schema(
|
|
|
|
{
|
|
|
|
key: SLOT_SCHEMA.extend({"value": validator})
|
|
|
|
for key, validator in self.slot_schema.items()
|
|
|
|
},
|
|
|
|
extra=vol.ALLOW_EXTRA,
|
|
|
|
)
|
|
|
|
|
2021-02-12 09:58:20 +00:00
|
|
|
async def async_handle(self, intent_obj: Intent) -> IntentResponse:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Handle the intent."""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
2018-10-28 19:12:52 +00:00
|
|
|
def __repr__(self) -> str:
|
2018-01-21 06:35:38 +00:00
|
|
|
"""Represent a string of an intent handler."""
|
2020-01-03 13:47:06 +00:00
|
|
|
return f"<{self.__class__.__name__} - {self.intent_type}>"
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
|
2018-02-11 17:33:19 +00:00
|
|
|
class ServiceIntentHandler(IntentHandler):
|
|
|
|
"""Service Intent handler registration.
|
|
|
|
|
|
|
|
Service specific intent handler that calls a service by name/entity_id.
|
|
|
|
"""
|
|
|
|
|
2023-01-07 21:20:21 +00:00
|
|
|
slot_schema = {
|
|
|
|
vol.Any("name", "area"): cv.string,
|
|
|
|
vol.Optional("domain"): vol.All(cv.ensure_list, [cv.string]),
|
|
|
|
vol.Optional("device_class"): vol.All(cv.ensure_list, [cv.string]),
|
|
|
|
}
|
2018-02-11 17:33:19 +00:00
|
|
|
|
2023-02-16 19:01:41 +00:00
|
|
|
# We use a small timeout in service calls to (hopefully) pass validation
|
|
|
|
# checks, but not try to wait for the call to fully complete.
|
|
|
|
service_timeout: float = 0.2
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
def __init__(
|
2024-02-20 03:28:42 +00:00
|
|
|
self,
|
|
|
|
intent_type: str,
|
|
|
|
domain: str,
|
|
|
|
service: str,
|
|
|
|
speech: str | None = None,
|
|
|
|
extra_slots: dict[str, vol.Schema] | None = None,
|
2019-07-31 19:25:30 +00:00
|
|
|
) -> None:
|
2018-02-11 17:33:19 +00:00
|
|
|
"""Create Service Intent Handler."""
|
|
|
|
self.intent_type = intent_type
|
|
|
|
self.domain = domain
|
|
|
|
self.service = service
|
|
|
|
self.speech = speech
|
2024-02-20 03:28:42 +00:00
|
|
|
self.extra_slots = extra_slots
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def _slot_schema(self) -> vol.Schema:
|
|
|
|
"""Create validation schema for slots (with extra required slots)."""
|
|
|
|
if self.slot_schema is None:
|
|
|
|
raise ValueError("Slot schema is not defined")
|
|
|
|
|
|
|
|
if self.extra_slots:
|
|
|
|
slot_schema = {
|
|
|
|
**self.slot_schema,
|
|
|
|
**{
|
|
|
|
vol.Required(key): schema
|
|
|
|
for key, schema in self.extra_slots.items()
|
|
|
|
},
|
|
|
|
}
|
|
|
|
else:
|
|
|
|
slot_schema = self.slot_schema
|
|
|
|
|
|
|
|
return vol.Schema(
|
|
|
|
{
|
|
|
|
key: SLOT_SCHEMA.extend({"value": validator})
|
|
|
|
for key, validator in slot_schema.items()
|
|
|
|
},
|
|
|
|
extra=vol.ALLOW_EXTRA,
|
|
|
|
)
|
2018-02-11 17:33:19 +00:00
|
|
|
|
2021-02-12 09:58:20 +00:00
|
|
|
async def async_handle(self, intent_obj: Intent) -> IntentResponse:
|
2018-02-11 17:33:19 +00:00
|
|
|
"""Handle the hass intent."""
|
|
|
|
hass = intent_obj.hass
|
|
|
|
slots = self.async_validate_slots(intent_obj.slots)
|
|
|
|
|
2024-02-03 11:14:33 +00:00
|
|
|
name_slot = slots.get("name", {})
|
2024-02-07 21:13:42 +00:00
|
|
|
entity_name: str | None = name_slot.get("value")
|
|
|
|
entity_text: str | None = name_slot.get("text")
|
|
|
|
if entity_name == "all":
|
2023-01-19 23:15:01 +00:00
|
|
|
# Don't match on name if targeting all entities
|
2024-02-07 21:13:42 +00:00
|
|
|
entity_name = None
|
2023-01-19 23:15:01 +00:00
|
|
|
|
|
|
|
# Look up area first to fail early
|
2024-02-03 11:14:33 +00:00
|
|
|
area_slot = slots.get("area", {})
|
|
|
|
area_id = area_slot.get("value")
|
|
|
|
area_name = area_slot.get("text")
|
2023-01-19 23:15:01 +00:00
|
|
|
area: area_registry.AreaEntry | None = None
|
2024-02-03 11:14:33 +00:00
|
|
|
if area_id is not None:
|
2023-01-19 23:15:01 +00:00
|
|
|
areas = area_registry.async_get(hass)
|
2024-02-09 01:38:03 +00:00
|
|
|
area = areas.async_get_area(area_id)
|
2023-01-19 23:15:01 +00:00
|
|
|
if area is None:
|
|
|
|
raise IntentHandleError(f"No area named {area_name}")
|
|
|
|
|
|
|
|
# Optional domain/device class filters.
|
|
|
|
# Convert to sets for speed.
|
|
|
|
domains: set[str] | None = None
|
|
|
|
device_classes: set[str] | None = None
|
|
|
|
|
|
|
|
if "domain" in slots:
|
|
|
|
domains = set(slots["domain"]["value"])
|
|
|
|
|
|
|
|
if "device_class" in slots:
|
|
|
|
device_classes = set(slots["device_class"]["value"])
|
|
|
|
|
|
|
|
states = list(
|
|
|
|
async_match_states(
|
|
|
|
hass,
|
2024-02-07 21:13:42 +00:00
|
|
|
name=entity_name,
|
2023-01-19 23:15:01 +00:00
|
|
|
area=area,
|
|
|
|
domains=domains,
|
|
|
|
device_classes=device_classes,
|
2023-05-03 16:18:31 +00:00
|
|
|
assistant=intent_obj.assistant,
|
2023-01-19 23:15:01 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
if not states:
|
2024-01-08 18:23:06 +00:00
|
|
|
# No states matched constraints
|
|
|
|
raise NoStatesMatchedError(
|
2024-02-07 21:13:42 +00:00
|
|
|
name=entity_text or entity_name,
|
2024-02-03 11:14:33 +00:00
|
|
|
area=area_name or area_id,
|
2024-01-08 18:23:06 +00:00
|
|
|
domains=domains,
|
|
|
|
device_classes=device_classes,
|
2023-02-10 04:39:46 +00:00
|
|
|
)
|
2023-01-07 21:20:21 +00:00
|
|
|
|
2024-02-09 01:38:03 +00:00
|
|
|
if entity_name and (len(states) > 1):
|
|
|
|
# Multiple entities matched for the same name
|
|
|
|
raise DuplicateNamesMatchedError(
|
|
|
|
name=entity_text or entity_name,
|
|
|
|
area=area_name or area_id,
|
|
|
|
)
|
|
|
|
|
2024-02-20 03:28:42 +00:00
|
|
|
# Update intent slots to include any transformations done by the schemas
|
|
|
|
intent_obj.slots = slots
|
|
|
|
|
2023-01-19 23:15:01 +00:00
|
|
|
response = await self.async_handle_states(intent_obj, states, area)
|
2023-01-07 21:20:21 +00:00
|
|
|
|
2024-02-07 21:13:42 +00:00
|
|
|
# Make the matched states available in the response
|
|
|
|
response.async_set_states(matched_states=states, unmatched_states=[])
|
|
|
|
|
2023-01-19 23:15:01 +00:00
|
|
|
return response
|
2018-02-11 17:33:19 +00:00
|
|
|
|
2023-01-19 23:15:01 +00:00
|
|
|
async def async_handle_states(
|
|
|
|
self,
|
|
|
|
intent_obj: Intent,
|
|
|
|
states: list[State],
|
|
|
|
area: area_registry.AreaEntry | None = None,
|
|
|
|
) -> IntentResponse:
|
|
|
|
"""Complete action on matched entity states."""
|
2023-02-16 19:01:41 +00:00
|
|
|
assert states, "No states"
|
|
|
|
hass = intent_obj.hass
|
2023-01-19 23:15:01 +00:00
|
|
|
success_results: list[IntentResponseTarget] = []
|
|
|
|
response = intent_obj.create_response()
|
|
|
|
|
|
|
|
if area is not None:
|
|
|
|
success_results.append(
|
2022-12-13 22:46:40 +00:00
|
|
|
IntentResponseTarget(
|
2023-01-07 21:20:21 +00:00
|
|
|
type=IntentResponseTargetType.AREA, name=area.name, id=area.id
|
|
|
|
)
|
|
|
|
)
|
2023-01-19 23:15:01 +00:00
|
|
|
speech_name = area.name
|
2023-01-07 21:20:21 +00:00
|
|
|
else:
|
2023-01-19 23:15:01 +00:00
|
|
|
speech_name = states[0].name
|
2023-01-07 21:20:21 +00:00
|
|
|
|
2024-01-08 09:09:48 +00:00
|
|
|
service_coros: list[Coroutine[Any, Any, None]] = []
|
2023-01-19 23:15:01 +00:00
|
|
|
for state in states:
|
|
|
|
service_coros.append(self.async_call_service(intent_obj, state))
|
2023-02-16 19:01:41 +00:00
|
|
|
|
|
|
|
# Handle service calls in parallel, noting failures as they occur.
|
|
|
|
failed_results: list[IntentResponseTarget] = []
|
|
|
|
for state, service_coro in zip(states, asyncio.as_completed(service_coros)):
|
|
|
|
target = IntentResponseTarget(
|
|
|
|
type=IntentResponseTargetType.ENTITY,
|
|
|
|
name=state.name,
|
|
|
|
id=state.entity_id,
|
2023-01-07 21:20:21 +00:00
|
|
|
)
|
|
|
|
|
2023-02-16 19:01:41 +00:00
|
|
|
try:
|
|
|
|
await service_coro
|
|
|
|
success_results.append(target)
|
|
|
|
except Exception: # pylint: disable=broad-except
|
|
|
|
failed_results.append(target)
|
|
|
|
_LOGGER.exception("Service call failed for %s", state.entity_id)
|
|
|
|
|
|
|
|
if not success_results:
|
|
|
|
# If no entities succeeded, raise an error.
|
|
|
|
failed_entity_ids = [target.id for target in failed_results]
|
|
|
|
raise IntentHandleError(
|
|
|
|
f"Failed to call {self.service} for: {failed_entity_ids}"
|
|
|
|
)
|
2023-01-19 23:15:01 +00:00
|
|
|
|
|
|
|
response.async_set_results(
|
2023-02-16 19:01:41 +00:00
|
|
|
success_results=success_results, failed_results=failed_results
|
2023-01-19 23:15:01 +00:00
|
|
|
)
|
2023-02-16 19:01:41 +00:00
|
|
|
|
|
|
|
# Update all states
|
|
|
|
states = [hass.states.get(state.entity_id) or state for state in states]
|
2023-02-10 04:39:46 +00:00
|
|
|
response.async_set_states(states)
|
2023-01-24 03:38:41 +00:00
|
|
|
|
|
|
|
if self.speech is not None:
|
|
|
|
response.async_set_speech(self.speech.format(speech_name))
|
2023-01-19 23:15:01 +00:00
|
|
|
|
2018-02-11 17:33:19 +00:00
|
|
|
return response
|
|
|
|
|
2023-01-19 23:15:01 +00:00
|
|
|
async def async_call_service(self, intent_obj: Intent, state: State) -> None:
|
|
|
|
"""Call service on entity."""
|
|
|
|
hass = intent_obj.hass
|
2024-02-20 03:28:42 +00:00
|
|
|
|
|
|
|
service_data: dict[str, Any] = {ATTR_ENTITY_ID: state.entity_id}
|
|
|
|
if self.extra_slots:
|
|
|
|
service_data.update(
|
|
|
|
{key: intent_obj.slots[key]["value"] for key in self.extra_slots}
|
|
|
|
)
|
|
|
|
|
2023-06-16 14:01:40 +00:00
|
|
|
await self._run_then_background(
|
|
|
|
hass.async_create_task(
|
|
|
|
hass.services.async_call(
|
|
|
|
self.domain,
|
|
|
|
self.service,
|
2024-02-20 03:28:42 +00:00
|
|
|
service_data,
|
2023-06-16 14:01:40 +00:00
|
|
|
context=intent_obj.context,
|
|
|
|
blocking=True,
|
|
|
|
),
|
|
|
|
f"intent_call_service_{self.domain}_{self.service}",
|
|
|
|
)
|
2023-01-19 23:15:01 +00:00
|
|
|
)
|
|
|
|
|
2024-01-08 09:09:48 +00:00
|
|
|
async def _run_then_background(self, task: asyncio.Task[Any]) -> None:
|
2023-06-16 14:01:40 +00:00
|
|
|
"""Run task with timeout to (hopefully) catch validation errors.
|
|
|
|
|
|
|
|
After the timeout the task will continue to run in the background.
|
|
|
|
"""
|
|
|
|
try:
|
|
|
|
await asyncio.wait({task}, timeout=self.service_timeout)
|
2024-02-05 11:09:54 +00:00
|
|
|
except TimeoutError:
|
2023-06-16 14:01:40 +00:00
|
|
|
pass
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
# Task calling us was cancelled, so cancel service call task, and wait for
|
|
|
|
# it to be cancelled, within reason, before leaving.
|
|
|
|
_LOGGER.debug("Service call was cancelled: %s", task.get_name())
|
|
|
|
task.cancel()
|
|
|
|
await asyncio.wait({task}, timeout=5)
|
|
|
|
raise
|
|
|
|
|
2018-02-11 17:33:19 +00:00
|
|
|
|
2022-12-09 01:30:08 +00:00
|
|
|
class IntentCategory(Enum):
|
|
|
|
"""Category of an intent."""
|
|
|
|
|
|
|
|
ACTION = "action"
|
|
|
|
"""Trigger an action like turning an entity on or off"""
|
|
|
|
|
|
|
|
QUERY = "query"
|
|
|
|
"""Get information about the state of an entity"""
|
|
|
|
|
|
|
|
|
2017-07-22 04:38:53 +00:00
|
|
|
class Intent:
|
|
|
|
"""Hold the intent."""
|
|
|
|
|
2022-12-08 16:39:28 +00:00
|
|
|
__slots__ = [
|
|
|
|
"hass",
|
|
|
|
"platform",
|
|
|
|
"intent_type",
|
|
|
|
"slots",
|
|
|
|
"text_input",
|
|
|
|
"context",
|
|
|
|
"language",
|
2022-12-09 01:30:08 +00:00
|
|
|
"category",
|
2023-05-03 16:18:31 +00:00
|
|
|
"assistant",
|
2022-12-08 16:39:28 +00:00
|
|
|
]
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
2021-03-27 11:55:24 +00:00
|
|
|
hass: HomeAssistant,
|
2019-07-31 19:25:30 +00:00
|
|
|
platform: str,
|
|
|
|
intent_type: str,
|
|
|
|
slots: _SlotsType,
|
2021-03-17 17:34:19 +00:00
|
|
|
text_input: str | None,
|
2019-11-26 10:30:21 +00:00
|
|
|
context: Context,
|
2022-12-08 16:39:28 +00:00
|
|
|
language: str,
|
2022-12-09 01:30:08 +00:00
|
|
|
category: IntentCategory | None = None,
|
2023-05-03 16:18:31 +00:00
|
|
|
assistant: str | None = None,
|
2019-07-31 19:25:30 +00:00
|
|
|
) -> None:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Initialize an intent."""
|
|
|
|
self.hass = hass
|
|
|
|
self.platform = platform
|
|
|
|
self.intent_type = intent_type
|
|
|
|
self.slots = slots
|
|
|
|
self.text_input = text_input
|
2019-11-26 10:30:21 +00:00
|
|
|
self.context = context
|
2022-12-08 16:39:28 +00:00
|
|
|
self.language = language
|
2022-12-09 01:30:08 +00:00
|
|
|
self.category = category
|
2023-05-03 16:18:31 +00:00
|
|
|
self.assistant = assistant
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
@callback
|
2021-02-12 09:58:20 +00:00
|
|
|
def create_response(self) -> IntentResponse:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Create a response."""
|
2022-12-13 22:46:40 +00:00
|
|
|
return IntentResponse(language=self.language, intent=self)
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
|
2022-12-09 01:30:08 +00:00
|
|
|
class IntentResponseType(Enum):
|
|
|
|
"""Type of the intent response."""
|
|
|
|
|
|
|
|
ACTION_DONE = "action_done"
|
|
|
|
"""Intent caused an action to occur"""
|
|
|
|
|
2022-12-13 22:46:40 +00:00
|
|
|
PARTIAL_ACTION_DONE = "partial_action_done"
|
|
|
|
"""Intent caused an action, but it could only be partially done"""
|
|
|
|
|
2022-12-09 01:30:08 +00:00
|
|
|
QUERY_ANSWER = "query_answer"
|
|
|
|
"""Response is an answer to a query"""
|
|
|
|
|
|
|
|
ERROR = "error"
|
|
|
|
"""Response is an error"""
|
|
|
|
|
|
|
|
|
|
|
|
class IntentResponseErrorCode(str, Enum):
|
|
|
|
"""Reason for an intent response error."""
|
|
|
|
|
|
|
|
NO_INTENT_MATCH = "no_intent_match"
|
|
|
|
"""Text could not be matched to an intent"""
|
|
|
|
|
|
|
|
NO_VALID_TARGETS = "no_valid_targets"
|
|
|
|
"""Intent was matched, but no valid areas/devices/entities were targeted"""
|
|
|
|
|
|
|
|
FAILED_TO_HANDLE = "failed_to_handle"
|
|
|
|
"""Unexpected error occurred while handling intent"""
|
|
|
|
|
2022-12-13 22:46:40 +00:00
|
|
|
UNKNOWN = "unknown"
|
|
|
|
"""Error outside the scope of intent processing"""
|
|
|
|
|
2022-12-09 01:30:08 +00:00
|
|
|
|
|
|
|
class IntentResponseTargetType(str, Enum):
|
|
|
|
"""Type of target for an intent response."""
|
|
|
|
|
|
|
|
AREA = "area"
|
|
|
|
DEVICE = "device"
|
|
|
|
ENTITY = "entity"
|
2022-12-13 22:46:40 +00:00
|
|
|
DOMAIN = "domain"
|
|
|
|
DEVICE_CLASS = "device_class"
|
|
|
|
CUSTOM = "custom"
|
2022-12-09 01:30:08 +00:00
|
|
|
|
|
|
|
|
2023-04-11 17:58:28 +00:00
|
|
|
@dataclass(slots=True)
|
2022-12-09 01:30:08 +00:00
|
|
|
class IntentResponseTarget:
|
2022-12-13 22:46:40 +00:00
|
|
|
"""Target of the intent response."""
|
2022-12-09 01:30:08 +00:00
|
|
|
|
|
|
|
name: str
|
|
|
|
type: IntentResponseTargetType
|
|
|
|
id: str | None = None
|
|
|
|
|
|
|
|
|
2017-07-22 04:38:53 +00:00
|
|
|
class IntentResponse:
|
|
|
|
"""Response to an intent."""
|
|
|
|
|
2022-12-08 16:39:28 +00:00
|
|
|
def __init__(
|
2022-12-09 01:30:08 +00:00
|
|
|
self,
|
2022-12-13 22:46:40 +00:00
|
|
|
language: str,
|
2022-12-09 01:30:08 +00:00
|
|
|
intent: Intent | None = None,
|
2022-12-08 16:39:28 +00:00
|
|
|
) -> None:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Initialize an IntentResponse."""
|
2022-12-13 22:46:40 +00:00
|
|
|
self.language = language
|
2017-07-22 04:38:53 +00:00
|
|
|
self.intent = intent
|
2021-03-17 17:34:19 +00:00
|
|
|
self.speech: dict[str, dict[str, Any]] = {}
|
2022-01-31 18:23:26 +00:00
|
|
|
self.reprompt: dict[str, dict[str, Any]] = {}
|
2021-03-17 17:34:19 +00:00
|
|
|
self.card: dict[str, dict[str, str]] = {}
|
2022-12-09 01:30:08 +00:00
|
|
|
self.error_code: IntentResponseErrorCode | None = None
|
2022-12-14 04:32:30 +00:00
|
|
|
self.intent_targets: list[IntentResponseTarget] = []
|
|
|
|
self.success_results: list[IntentResponseTarget] = []
|
|
|
|
self.failed_results: list[IntentResponseTarget] = []
|
2023-02-10 04:39:46 +00:00
|
|
|
self.matched_states: list[State] = []
|
|
|
|
self.unmatched_states: list[State] = []
|
2022-12-09 01:30:08 +00:00
|
|
|
|
|
|
|
if (self.intent is not None) and (self.intent.category == IntentCategory.QUERY):
|
|
|
|
# speech will be the answer to the query
|
|
|
|
self.response_type = IntentResponseType.QUERY_ANSWER
|
|
|
|
else:
|
|
|
|
self.response_type = IntentResponseType.ACTION_DONE
|
2017-07-22 04:38:53 +00:00
|
|
|
|
|
|
|
@callback
|
2019-07-31 19:25:30 +00:00
|
|
|
def async_set_speech(
|
2022-12-08 16:39:28 +00:00
|
|
|
self,
|
|
|
|
speech: str,
|
|
|
|
speech_type: str = "plain",
|
|
|
|
extra_data: Any | None = None,
|
2019-07-31 19:25:30 +00:00
|
|
|
) -> None:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Set speech response."""
|
2022-12-08 16:39:28 +00:00
|
|
|
self.speech[speech_type] = {
|
|
|
|
"speech": speech,
|
|
|
|
"extra_data": extra_data,
|
|
|
|
}
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2022-01-31 18:23:26 +00:00
|
|
|
@callback
|
|
|
|
def async_set_reprompt(
|
2022-12-08 16:39:28 +00:00
|
|
|
self,
|
|
|
|
speech: str,
|
|
|
|
speech_type: str = "plain",
|
|
|
|
extra_data: Any | None = None,
|
2022-01-31 18:23:26 +00:00
|
|
|
) -> None:
|
|
|
|
"""Set reprompt response."""
|
2022-12-08 16:39:28 +00:00
|
|
|
self.reprompt[speech_type] = {
|
|
|
|
"reprompt": speech,
|
|
|
|
"extra_data": extra_data,
|
|
|
|
}
|
2022-01-31 18:23:26 +00:00
|
|
|
|
2017-07-22 04:38:53 +00:00
|
|
|
@callback
|
2019-07-31 19:25:30 +00:00
|
|
|
def async_set_card(
|
|
|
|
self, title: str, content: str, card_type: str = "simple"
|
|
|
|
) -> None:
|
2022-01-31 18:23:26 +00:00
|
|
|
"""Set card response."""
|
2019-07-31 19:25:30 +00:00
|
|
|
self.card[card_type] = {"title": title, "content": content}
|
2017-07-22 04:38:53 +00:00
|
|
|
|
2022-12-09 01:30:08 +00:00
|
|
|
@callback
|
|
|
|
def async_set_error(self, code: IntentResponseErrorCode, message: str) -> None:
|
|
|
|
"""Set response error."""
|
|
|
|
self.response_type = IntentResponseType.ERROR
|
|
|
|
self.error_code = code
|
|
|
|
|
|
|
|
# Speak error message
|
|
|
|
self.async_set_speech(message)
|
|
|
|
|
|
|
|
@callback
|
2022-12-14 04:32:30 +00:00
|
|
|
def async_set_targets(
|
|
|
|
self,
|
|
|
|
intent_targets: list[IntentResponseTarget],
|
|
|
|
) -> None:
|
2022-12-13 22:46:40 +00:00
|
|
|
"""Set response targets."""
|
2022-12-14 04:32:30 +00:00
|
|
|
self.intent_targets = intent_targets
|
2022-12-13 22:46:40 +00:00
|
|
|
|
|
|
|
@callback
|
2022-12-14 04:32:30 +00:00
|
|
|
def async_set_results(
|
2022-12-13 22:46:40 +00:00
|
|
|
self,
|
2022-12-14 04:32:30 +00:00
|
|
|
success_results: list[IntentResponseTarget],
|
|
|
|
failed_results: list[IntentResponseTarget] | None = None,
|
2022-12-13 22:46:40 +00:00
|
|
|
) -> None:
|
2022-12-14 04:32:30 +00:00
|
|
|
"""Set response results."""
|
|
|
|
self.success_results = success_results
|
|
|
|
self.failed_results = failed_results if failed_results is not None else []
|
2022-12-09 01:30:08 +00:00
|
|
|
|
2023-02-10 04:39:46 +00:00
|
|
|
@callback
|
|
|
|
def async_set_states(
|
|
|
|
self, matched_states: list[State], unmatched_states: list[State] | None = None
|
|
|
|
) -> None:
|
|
|
|
"""Set entity states that were matched or not matched during intent handling (query)."""
|
|
|
|
self.matched_states = matched_states
|
|
|
|
self.unmatched_states = unmatched_states or []
|
|
|
|
|
2017-07-22 04:38:53 +00:00
|
|
|
@callback
|
2022-12-08 16:39:28 +00:00
|
|
|
def as_dict(self) -> dict[str, Any]:
|
2017-07-22 04:38:53 +00:00
|
|
|
"""Return a dictionary representation of an intent response."""
|
2022-12-08 16:39:28 +00:00
|
|
|
response_dict: dict[str, Any] = {
|
|
|
|
"speech": self.speech,
|
|
|
|
"card": self.card,
|
|
|
|
"language": self.language,
|
2022-12-09 01:30:08 +00:00
|
|
|
"response_type": self.response_type.value,
|
2022-12-08 16:39:28 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if self.reprompt:
|
|
|
|
response_dict["reprompt"] = self.reprompt
|
|
|
|
|
2022-12-09 01:30:08 +00:00
|
|
|
response_data: dict[str, Any] = {}
|
|
|
|
|
|
|
|
if self.response_type == IntentResponseType.ERROR:
|
|
|
|
assert self.error_code is not None, "error code is required"
|
|
|
|
response_data["code"] = self.error_code.value
|
|
|
|
else:
|
|
|
|
# action done or query answer
|
2022-12-13 22:46:40 +00:00
|
|
|
response_data["targets"] = [
|
2022-12-14 04:32:30 +00:00
|
|
|
dataclasses.asdict(target) for target in self.intent_targets
|
2022-12-13 22:46:40 +00:00
|
|
|
]
|
|
|
|
|
2022-12-14 04:32:30 +00:00
|
|
|
# Add success/failed targets
|
|
|
|
response_data["success"] = [
|
|
|
|
dataclasses.asdict(target) for target in self.success_results
|
|
|
|
]
|
2022-12-13 22:46:40 +00:00
|
|
|
|
2022-12-14 04:32:30 +00:00
|
|
|
response_data["failed"] = [
|
|
|
|
dataclasses.asdict(target) for target in self.failed_results
|
|
|
|
]
|
2022-12-09 01:30:08 +00:00
|
|
|
|
|
|
|
response_dict["data"] = response_data
|
|
|
|
|
2022-12-08 16:39:28 +00:00
|
|
|
return response_dict
|