429 files
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,7 @@ from homeassistant.components import automation
|
||||
from homeassistant.helpers import area_registry as ar
|
||||
|
||||
from ....entity_filtering import async_filter_known_area_ids, async_get_all_area_ids
|
||||
from ....reference_extraction import extract_targets_from_config
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -38,8 +39,15 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown area IDs referenced by ``entity``."""
|
||||
area_ids = set(entity.referenced_areas)
|
||||
|
||||
# Also walk the raw configuration; the built-in extraction misses
|
||||
# references nested in some step types, like repeat sequences.
|
||||
if raw_config := getattr(entity, "raw_config", None):
|
||||
area_ids.update(extract_targets_from_config(raw_config).area_ids)
|
||||
|
||||
return async_filter_known_area_ids(
|
||||
self.hass,
|
||||
area_ids=entity.referenced_areas,
|
||||
area_ids=area_ids,
|
||||
known_area_ids=self._known_area_ids,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,9 @@ from homeassistant.components import automation
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from ....entity_filtering import async_filter_known_device_ids, async_get_all_device_ids
|
||||
from ....reference_extraction import extract_targets_from_config
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
from ....template_extraction import extract_device_ids_from_config
|
||||
|
||||
|
||||
def extract_event_data_device_ids_from_trigger_config(
|
||||
@@ -72,6 +74,11 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
device_ids = set(entity.referenced_devices)
|
||||
|
||||
if hasattr(entity, "raw_config") and entity.raw_config:
|
||||
# Also walk the raw configuration; the built-in extraction misses
|
||||
# references nested in some step types, like repeat sequences.
|
||||
# The walker never collects from event trigger payloads, so the
|
||||
# subtraction below only affects built-in extraction results.
|
||||
device_ids.update(extract_targets_from_config(entity.raw_config).device_ids)
|
||||
device_ids.difference_update(
|
||||
extract_event_data_device_ids_from_trigger_config(
|
||||
entity.raw_config.get("trigger")
|
||||
@@ -82,6 +89,8 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
entity.raw_config.get("triggers")
|
||||
)
|
||||
)
|
||||
# Devices referenced via device_entities() in templates.
|
||||
device_ids.update(extract_device_ids_from_config(entity.raw_config))
|
||||
|
||||
return async_filter_known_device_ids(
|
||||
self.hass,
|
||||
|
||||
+135
-174
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components import automation
|
||||
@@ -10,16 +9,17 @@ from homeassistant.const import EVENT_COMPONENT_LOADED, EVENT_STATE_CHANGED
|
||||
from homeassistant.core import Event, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ....const import LOGGER
|
||||
from ....entity_filtering import (
|
||||
ENTITY_ID_PATTERN,
|
||||
async_extract_entities_from_config,
|
||||
async_extract_entities_from_template_string,
|
||||
async_filter_known_entity_ids_with_templates,
|
||||
async_get_all_entity_ids,
|
||||
is_template_string,
|
||||
from ....action_extraction import (
|
||||
async_extract_entities_from_action_config,
|
||||
async_extract_entities_from_value,
|
||||
)
|
||||
from ....entity_filtering import async_get_all_entity_ids, async_get_all_services
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
from ....template_extraction import (
|
||||
KNOWN_DOMAINS,
|
||||
async_extract_entities_from_config,
|
||||
async_filter_known_entity_ids_with_templates,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
@@ -28,7 +28,9 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
async def extract_template_entities_from_automation_entity(
|
||||
hass: HomeAssistant, entity: Any
|
||||
hass: HomeAssistant,
|
||||
entity: Any,
|
||||
known_services: set[str] | None = None,
|
||||
) -> set[str]:
|
||||
"""Extract entities from automation configuration using Template analysis.
|
||||
|
||||
@@ -43,44 +45,88 @@ async def extract_template_entities_from_automation_entity(
|
||||
else:
|
||||
return set()
|
||||
|
||||
return await async_extract_entities_from_config(hass, config)
|
||||
return await async_extract_entities_from_config(hass, config, known_services)
|
||||
|
||||
|
||||
async def extract_entities_from_automation_config(
|
||||
hass: HomeAssistant, config: dict[str, Any]
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any],
|
||||
known_services: set[str] | None = None,
|
||||
) -> set[str]:
|
||||
"""Extract entity IDs from automation configuration."""
|
||||
"""Extract entity IDs from automation configuration.
|
||||
|
||||
``known_services`` is built once per inspection and handed down, because
|
||||
building it flattens every service Home Assistant has and the walk below
|
||||
passes a lot of template strings. See
|
||||
`action_extraction.async_extract_entities_from_action_config`.
|
||||
"""
|
||||
entities = set()
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return entities
|
||||
|
||||
if known_services is None:
|
||||
known_services = async_get_all_services(hass)
|
||||
|
||||
# Extract entities from trigger config
|
||||
for key in ("trigger", "triggers"):
|
||||
if key in config:
|
||||
entities.update(
|
||||
await extract_entities_from_trigger_config(hass, config[key])
|
||||
await extract_entities_from_trigger_config(
|
||||
hass, config[key], known_services
|
||||
)
|
||||
)
|
||||
|
||||
# Extract entities from condition config
|
||||
for key in ("condition", "conditions"):
|
||||
if key in config:
|
||||
entities.update(
|
||||
await extract_entities_from_condition_config(hass, config[key])
|
||||
await extract_entities_from_condition_config(
|
||||
hass, config[key], known_services
|
||||
)
|
||||
)
|
||||
|
||||
# Extract entities from action config
|
||||
for key in ("action", "actions"):
|
||||
if key in config:
|
||||
entities.update(
|
||||
await extract_entities_from_action_config(hass, config[key])
|
||||
await async_extract_entities_from_action_config(
|
||||
hass, config[key], known_services=known_services
|
||||
)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
async def _entities_from_reference_fields(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any],
|
||||
known_services: set[str],
|
||||
) -> set[str]:
|
||||
"""Extract entities from the config keys that name a reference.
|
||||
|
||||
``zone`` is in here because a zone trigger and a zone condition both name
|
||||
one, and it is read exactly like the others.
|
||||
"""
|
||||
entities = set()
|
||||
for key in ("entity_id", "device_id", "zone"):
|
||||
if key in config:
|
||||
entities.update(
|
||||
await async_extract_entities_from_value(
|
||||
hass, config[key], known_services=known_services
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
|
||||
# Where an event trigger keeps what it matches on, rather than what it needs.
|
||||
_EVENT_PAYLOAD_KEYS = frozenset({"event_data", "event_data_template"})
|
||||
|
||||
|
||||
async def extract_entities_from_trigger_config(
|
||||
hass: HomeAssistant, config: dict[str, Any] | list
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any] | list,
|
||||
known_services: set[str] | None = None,
|
||||
) -> set[str]:
|
||||
"""Extract entity IDs from trigger configuration."""
|
||||
entities = set()
|
||||
@@ -88,31 +134,66 @@ async def extract_entities_from_trigger_config(
|
||||
if not config:
|
||||
return entities
|
||||
|
||||
if known_services is None:
|
||||
known_services = async_get_all_services(hass)
|
||||
|
||||
if isinstance(config, list):
|
||||
for item in config:
|
||||
entities.update(await extract_entities_from_trigger_config(hass, item))
|
||||
entities.update(
|
||||
await extract_entities_from_trigger_config(hass, item, known_services)
|
||||
)
|
||||
return entities
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return entities
|
||||
|
||||
# Entity ID fields in triggers
|
||||
for key in ("entity_id", "device_id"):
|
||||
if key in config:
|
||||
entities.update(await extract_entities_from_value(hass, config[key]))
|
||||
entities.update(await _entities_from_reference_fields(hass, config, known_services))
|
||||
|
||||
# Zone trigger has zone field
|
||||
if "zone" in config:
|
||||
entities.update(await extract_entities_from_value(hass, config["zone"]))
|
||||
payload_keys = _payload_keys_to_leave_alone(config)
|
||||
|
||||
# Extract from nested configs
|
||||
for value in config.values():
|
||||
for key, value in config.items():
|
||||
if key in payload_keys:
|
||||
continue
|
||||
|
||||
if isinstance(value, (dict, list)):
|
||||
entities.update(await extract_entities_from_trigger_config(hass, value))
|
||||
entities.update(
|
||||
await extract_entities_from_trigger_config(hass, value, known_services)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
def _payload_keys_to_leave_alone(config: dict[str, Any]) -> frozenset[str]:
|
||||
"""Return the event payload keys that hold data rather than references.
|
||||
|
||||
An `entity_id` inside `event_data` is a reference when the event comes
|
||||
from an integration that means it that way, `timer.finished` being the
|
||||
usual one. On somebody's own event it is whatever the sender put there,
|
||||
and reporting that as a missing entity is a repair about an automation
|
||||
that works perfectly well.
|
||||
|
||||
Told apart by the event type: one named after a domain comes from that
|
||||
integration, anything else is somebody's own.
|
||||
"""
|
||||
event_types = config.get("event_type")
|
||||
if event_types is None:
|
||||
return frozenset()
|
||||
|
||||
if isinstance(event_types, str):
|
||||
event_types = [event_types]
|
||||
|
||||
for event_type in event_types:
|
||||
if not isinstance(event_type, str):
|
||||
continue
|
||||
|
||||
domain, dot, _ = event_type.partition(".")
|
||||
if dot and domain in KNOWN_DOMAINS:
|
||||
return frozenset()
|
||||
|
||||
return _EVENT_PAYLOAD_KEYS
|
||||
|
||||
|
||||
def extract_event_types_from_trigger_config(config: dict[str, Any] | list) -> set[str]:
|
||||
"""Extract event types from trigger configuration."""
|
||||
event_types = set()
|
||||
@@ -142,7 +223,9 @@ def extract_event_types_from_trigger_config(config: dict[str, Any] | list) -> se
|
||||
|
||||
|
||||
async def extract_entities_from_condition_config(
|
||||
hass: HomeAssistant, config: dict[str, Any] | list
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any] | list,
|
||||
known_services: set[str] | None = None,
|
||||
) -> set[str]:
|
||||
"""Extract entity IDs from condition configuration."""
|
||||
entities = set()
|
||||
@@ -150,161 +233,29 @@ async def extract_entities_from_condition_config(
|
||||
if not config:
|
||||
return entities
|
||||
|
||||
if known_services is None:
|
||||
known_services = async_get_all_services(hass)
|
||||
|
||||
if isinstance(config, list):
|
||||
for item in config:
|
||||
entities.update(await extract_entities_from_condition_config(hass, item))
|
||||
entities.update(
|
||||
await extract_entities_from_condition_config(hass, item, known_services)
|
||||
)
|
||||
return entities
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return entities
|
||||
|
||||
# Entity ID fields in conditions
|
||||
for key in ("entity_id", "device_id", "zone"):
|
||||
if key in config:
|
||||
entities.update(await extract_entities_from_value(hass, config[key]))
|
||||
entities.update(await _entities_from_reference_fields(hass, config, known_services))
|
||||
|
||||
# Extract from nested configs
|
||||
for value in config.values():
|
||||
if isinstance(value, (dict, list)):
|
||||
entities.update(await extract_entities_from_condition_config(hass, value))
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
async def extract_entities_from_action_config(
|
||||
hass: HomeAssistant, config: dict[str, Any] | list
|
||||
) -> set[str]:
|
||||
"""Extract entity IDs from action configuration."""
|
||||
entities = set()
|
||||
|
||||
if not config:
|
||||
return entities
|
||||
|
||||
if isinstance(config, list):
|
||||
for item in config:
|
||||
entities.update(await extract_entities_from_action_config(hass, item))
|
||||
return entities
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return entities
|
||||
|
||||
# Extract entity IDs from direct fields
|
||||
entities.update(await _extract_entities_from_action_fields(hass, config))
|
||||
|
||||
# Extract entities from target configuration
|
||||
entities.update(await _extract_entities_from_target(hass, config))
|
||||
|
||||
# Extract entities from service data
|
||||
entities.update(await _extract_entities_from_service_data(hass, config))
|
||||
|
||||
# Extract from nested configs (like if/then/else, repeat, etc.)
|
||||
entities.update(await _extract_entities_from_nested_configs(hass, config))
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
async def _extract_entities_from_action_fields(
|
||||
hass: HomeAssistant, config: dict[str, Any]
|
||||
) -> set[str]:
|
||||
"""Extract entities from direct action fields."""
|
||||
entities = set()
|
||||
for key in ("entity_id", "device_id"):
|
||||
if key in config:
|
||||
entities.update(await extract_entities_from_value(hass, config[key]))
|
||||
return entities
|
||||
|
||||
|
||||
async def _extract_entities_from_target(
|
||||
hass: HomeAssistant, config: dict[str, Any]
|
||||
) -> set[str]:
|
||||
"""Extract entities from target configuration."""
|
||||
entities = set()
|
||||
if "target" in config and isinstance(config["target"], dict):
|
||||
target = config["target"]
|
||||
for key in ("entity_id", "device_id", "area_id", "label_id"):
|
||||
if key in target:
|
||||
entities.update(await extract_entities_from_value(hass, target[key]))
|
||||
return entities
|
||||
|
||||
|
||||
def _get_action_service(config: dict[str, Any]) -> str | None:
|
||||
"""Return the service/action name configured for an action."""
|
||||
service = config.get("service", config.get("action"))
|
||||
return service if isinstance(service, str) else None
|
||||
|
||||
|
||||
def _should_skip_service_data_value(
|
||||
service: str | None,
|
||||
key: str,
|
||||
) -> bool:
|
||||
"""Return if a service data value should not be scanned for entity IDs."""
|
||||
return service is not None and service.startswith("notify.") and key == "target"
|
||||
|
||||
|
||||
async def _extract_entities_from_service_data(
|
||||
hass: HomeAssistant, config: dict[str, Any]
|
||||
) -> set[str]:
|
||||
"""Extract entities from service data."""
|
||||
entities = set()
|
||||
if "data" in config:
|
||||
data_value = config["data"]
|
||||
if isinstance(data_value, str):
|
||||
# data field is a template string itself
|
||||
entities.update(await extract_entities_from_value(hass, data_value))
|
||||
elif isinstance(data_value, dict):
|
||||
service = _get_action_service(config)
|
||||
# data field is a dictionary, process all its values
|
||||
for key, value in data_value.items():
|
||||
if _should_skip_service_data_value(service, key):
|
||||
continue
|
||||
entities.update(await extract_entities_from_value(hass, value))
|
||||
return entities
|
||||
|
||||
|
||||
async def _extract_entities_from_nested_configs(
|
||||
hass: HomeAssistant, config: dict[str, Any]
|
||||
) -> set[str]:
|
||||
"""Extract entities from nested configurations."""
|
||||
entities = set()
|
||||
for value in config.values():
|
||||
if isinstance(value, (dict, list)):
|
||||
entities.update(await extract_entities_from_action_config(hass, value))
|
||||
return entities
|
||||
|
||||
|
||||
async def extract_entities_from_value(hass: HomeAssistant, value: Any) -> set[str]:
|
||||
"""Extract entity IDs from a configuration value."""
|
||||
entities = set()
|
||||
|
||||
if isinstance(value, str):
|
||||
# Check if it's a template string using util.is_template_string
|
||||
if is_template_string(value):
|
||||
# Process as template to extract entity references
|
||||
try:
|
||||
template_entities = await async_extract_entities_from_template_string(
|
||||
hass, value
|
||||
entities.update(
|
||||
await extract_entities_from_condition_config(
|
||||
hass, value, known_services
|
||||
)
|
||||
entities.update(template_entities)
|
||||
# pylint: disable-next=broad-exception-caught
|
||||
except Exception as exc: # noqa: BLE001 - Keep broad for unexpected template issues
|
||||
LOGGER.debug(
|
||||
"Failed to extract entities from template: %s, error: %s",
|
||||
value,
|
||||
exc,
|
||||
)
|
||||
elif re.match(rf"^{ENTITY_ID_PATTERN}$", value):
|
||||
# Check if it matches the entity ID pattern with known domains
|
||||
entities.add(value)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
entities.update(await extract_entities_from_value(hass, item))
|
||||
elif (
|
||||
isinstance(value, dict)
|
||||
and "entity" in value
|
||||
and isinstance(value["entity"], str)
|
||||
):
|
||||
# Handle entity dict format like {"entity": "light.living_room"}
|
||||
entities.add(value["entity"])
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
@@ -324,9 +275,11 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
unavailable_entity_class = automation.UnavailableAutomationEntity
|
||||
entity_label = "automation"
|
||||
reference_label = "entities"
|
||||
references_are_entities = True
|
||||
edit_url_pattern = "/config/automation/edit/{unique_id}"
|
||||
|
||||
_known_entity_ids: set[str]
|
||||
_known_services: set[str]
|
||||
|
||||
async def async_activate(self) -> None:
|
||||
"""Activate the repair."""
|
||||
@@ -354,10 +307,16 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
)
|
||||
|
||||
async def _async_setup_inspection(self) -> None:
|
||||
"""Cache known entity IDs (including ALL/NONE) for this inspection cycle."""
|
||||
"""Cache what every automation in this cycle needs looked up.
|
||||
|
||||
The service set is in here for the same reason as the entity ids:
|
||||
building it flattens every service Home Assistant has, and it is the
|
||||
same answer for every automation in one pass.
|
||||
"""
|
||||
self._known_entity_ids = async_get_all_entity_ids(
|
||||
self.hass, include_all_none=True
|
||||
)
|
||||
self._known_services = async_get_all_services(self.hass)
|
||||
|
||||
def _should_inspect_entity(self, entity: Any) -> bool:
|
||||
"""Skip disabled automations."""
|
||||
@@ -371,7 +330,7 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
if hasattr(entity, "raw_config") and entity.raw_config:
|
||||
all_entities.update(
|
||||
await extract_entities_from_automation_config(
|
||||
self.hass, entity.raw_config
|
||||
self.hass, entity.raw_config, self._known_services
|
||||
)
|
||||
)
|
||||
for key in ("trigger", "triggers"):
|
||||
@@ -381,7 +340,9 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
|
||||
# Extract entities from Template objects within the automation entity
|
||||
all_entities.update(
|
||||
await extract_template_entities_from_automation_entity(self.hass, entity)
|
||||
await extract_template_entities_from_automation_entity(
|
||||
self.hass, entity, self._known_services
|
||||
)
|
||||
)
|
||||
|
||||
return await async_filter_known_entity_ids_with_templates(
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.components import automation
|
||||
from homeassistant.helpers import floor_registry as fr
|
||||
|
||||
from ....entity_filtering import async_filter_known_floor_ids, async_get_all_floor_ids
|
||||
from ....reference_extraction import extract_targets_from_config
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -37,8 +38,15 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown floor IDs referenced by ``entity``."""
|
||||
floor_ids = set(entity.referenced_floors)
|
||||
|
||||
# Also walk the raw configuration; the built-in extraction misses
|
||||
# references nested in some step types, like repeat sequences.
|
||||
if raw_config := getattr(entity, "raw_config", None):
|
||||
floor_ids.update(extract_targets_from_config(raw_config).floor_ids)
|
||||
|
||||
return async_filter_known_floor_ids(
|
||||
self.hass,
|
||||
floor_ids=entity.referenced_floors,
|
||||
floor_ids=floor_ids,
|
||||
known_floor_ids=self._known_floor_ids,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.components import automation
|
||||
from homeassistant.helpers import label_registry as lr
|
||||
|
||||
from ....entity_filtering import async_filter_known_label_ids, async_get_all_label_ids
|
||||
from ....reference_extraction import extract_targets_from_config
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -37,8 +38,15 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown label IDs referenced by ``entity``."""
|
||||
label_ids = set(entity.referenced_labels)
|
||||
|
||||
# Also walk the raw configuration; the built-in extraction misses
|
||||
# references nested in some step types, like repeat sequences.
|
||||
if raw_config := getattr(entity, "raw_config", None):
|
||||
label_ids.update(extract_targets_from_config(raw_config).label_ids)
|
||||
|
||||
return async_filter_known_label_ids(
|
||||
self.hass,
|
||||
label_ids=entity.referenced_labels,
|
||||
label_ids=label_ids,
|
||||
known_label_ids=self._known_label_ids,
|
||||
)
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -108,7 +108,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class HomeAssistantCloudSpookSwitchEntity(HomeAssistantCloudSpookEntity, SwitchEntity):
|
||||
"""Spook switch providig Home Asistant Cloud controls."""
|
||||
"""Spook switch providing Home Assistant Cloud controls."""
|
||||
|
||||
entity_description: HomeAssistantCloudSpookSwitchEntityDescription
|
||||
|
||||
|
||||
Binary file not shown.
@@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import DATA_ENTITY_PLATFORM, EntityPl
|
||||
|
||||
from ....const import LOGGER
|
||||
from ....entity_filtering import async_filter_known_entity_ids, async_get_all_entity_ids
|
||||
from ....entity_suggestions import async_describe_unknown_entities
|
||||
from ....repairs import AbstractSpookRepair
|
||||
|
||||
|
||||
@@ -55,12 +56,19 @@ class SpookRepair(AbstractSpookRepair):
|
||||
if unknown_entities := async_filter_known_entity_ids(
|
||||
self.hass, entity_ids=members, known_entity_ids=known_entity_ids
|
||||
):
|
||||
described = async_describe_unknown_entities(
|
||||
self.hass, sorted(unknown_entities)
|
||||
)
|
||||
self.async_create_issue(
|
||||
issue_id=entity.entity_id,
|
||||
is_fixable=True,
|
||||
data={
|
||||
"group_entity_id": entity.entity_id,
|
||||
"group": entity.name,
|
||||
"entities": described,
|
||||
},
|
||||
translation_placeholders={
|
||||
"entities": "\n".join(
|
||||
f"- `{entity_id}`" for entity_id in unknown_entities
|
||||
),
|
||||
"entities": described,
|
||||
"group": entity.name,
|
||||
"entity_id": entity.entity_id,
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -78,7 +78,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class HomeAssistantSpookButtonEntity(HomeAssistantSpookEntity, ButtonEntity):
|
||||
"""Spook button providig Home Asistant actions."""
|
||||
"""Spook button providing Home Assistant actions."""
|
||||
|
||||
entity_description: HomeAssistantSpookButtonEntityDescription
|
||||
|
||||
|
||||
@@ -2,38 +2,88 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from ...core_compat import (
|
||||
async_get_child_devices_for_parent,
|
||||
async_get_device_entries,
|
||||
async_is_child_device,
|
||||
async_update_any_device,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_get_parent_device_id(device: Any) -> str | None:
|
||||
"""Return the device a device hangs off, if it hangs off one at all.
|
||||
|
||||
A child device names its parent device, everything else names the device
|
||||
it is connected through.
|
||||
"""
|
||||
if async_is_child_device(device):
|
||||
return device.parent_device_id
|
||||
|
||||
return device.via_device_id
|
||||
|
||||
|
||||
@callback
|
||||
def _async_get_devices_hanging_off(
|
||||
device_registry: dr.DeviceRegistry,
|
||||
device_id: str,
|
||||
) -> list[Any]:
|
||||
"""Return the devices that hang off a device.
|
||||
|
||||
A device can be the parent of child devices and the device others are
|
||||
connected through at the same time. Both hang off it.
|
||||
"""
|
||||
return [
|
||||
*async_get_child_devices_for_parent(device_registry, device_id),
|
||||
*(
|
||||
device
|
||||
for device in async_get_device_entries(device_registry)
|
||||
if device.via_device_id == device_id
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@callback
|
||||
def async_disable_device_and_parent_if_needed(
|
||||
device_registry: dr.DeviceRegistry,
|
||||
device_id: str,
|
||||
) -> None:
|
||||
"""Disable a device and its parent when no enabled child devices remain."""
|
||||
device = device_registry.async_get(device_id)
|
||||
if device is None:
|
||||
return
|
||||
"""Disable a device and its parent when nothing enabled hangs off it."""
|
||||
# A registry can contain via_device_id cycles between distinct devices,
|
||||
# so track visited devices to avoid walking the chain forever.
|
||||
seen: set[str] = set()
|
||||
current_id: str | None = device_id
|
||||
while current_id is not None and current_id not in seen:
|
||||
seen.add(current_id)
|
||||
device = device_registry.async_get(current_id)
|
||||
if device is None:
|
||||
return
|
||||
|
||||
if device.disabled_by is None:
|
||||
device_registry.async_update_device(
|
||||
device_id=device_id,
|
||||
disabled_by=dr.DeviceEntryDisabler.USER,
|
||||
)
|
||||
if device.disabled_by is None:
|
||||
async_update_any_device(
|
||||
device_registry,
|
||||
current_id,
|
||||
disabled_by=dr.DeviceEntryDisabler.USER,
|
||||
)
|
||||
|
||||
if device.via_device_id is None:
|
||||
return
|
||||
parent_id = _async_get_parent_device_id(device)
|
||||
if parent_id is None:
|
||||
return
|
||||
|
||||
if all(
|
||||
child.id == device_id or child.disabled_by is not None
|
||||
for child in device_registry.devices.values()
|
||||
if child.via_device_id == device.via_device_id
|
||||
):
|
||||
async_disable_device_and_parent_if_needed(
|
||||
device_registry,
|
||||
device.via_device_id,
|
||||
)
|
||||
if not all(
|
||||
hanging_device.id == current_id or hanging_device.disabled_by is not None
|
||||
for hanging_device in _async_get_devices_hanging_off(
|
||||
device_registry, parent_id
|
||||
)
|
||||
):
|
||||
return
|
||||
|
||||
current_id = parent_id
|
||||
|
||||
|
||||
@callback
|
||||
@@ -42,14 +92,21 @@ def async_enable_device_and_parent(
|
||||
device_id: str,
|
||||
) -> None:
|
||||
"""Enable a device and its parent device chain."""
|
||||
device = device_registry.async_get(device_id)
|
||||
if device is None:
|
||||
return
|
||||
# A registry can contain via_device_id cycles between distinct devices,
|
||||
# so track visited devices to avoid walking the chain forever.
|
||||
chain: list[str] = []
|
||||
seen: set[str] = set()
|
||||
current_id: str | None = device_id
|
||||
while current_id is not None and current_id not in seen:
|
||||
device = device_registry.async_get(current_id)
|
||||
if device is None:
|
||||
break
|
||||
seen.add(current_id)
|
||||
chain.append(current_id)
|
||||
current_id = _async_get_parent_device_id(device)
|
||||
|
||||
if device.via_device_id is not None:
|
||||
async_enable_device_and_parent(device_registry, device.via_device_id)
|
||||
|
||||
device_registry.async_update_device(
|
||||
device_id=device_id,
|
||||
disabled_by=None,
|
||||
)
|
||||
# Enable parents before their children, like the previous recursive walk.
|
||||
# Home Assistant Core refuses to enable a child device while its parent is
|
||||
# disabled, so the order is not just cosmetic.
|
||||
for chain_device_id in reversed(chain):
|
||||
async_update_any_device(device_registry, chain_device_id, disabled_by=None)
|
||||
|
||||
@@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components import (
|
||||
automation,
|
||||
counter,
|
||||
group,
|
||||
input_boolean,
|
||||
input_button,
|
||||
input_datetime,
|
||||
@@ -17,6 +19,7 @@ from homeassistant.components import (
|
||||
person,
|
||||
script,
|
||||
sun,
|
||||
timer,
|
||||
zone,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -165,6 +168,16 @@ SENSORS: tuple[HomeAssistantSpookSensorEntityDescription, ...] = (
|
||||
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
|
||||
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.CLIMATE)),
|
||||
),
|
||||
HomeAssistantSpookSensorEntityDescription(
|
||||
key=counter.DOMAIN,
|
||||
translation_key="homeassistant_counter",
|
||||
entity_id="sensor.counters",
|
||||
icon="mdi:counter",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
|
||||
value_fn=lambda hass: len(hass.states.async_entity_ids(counter.DOMAIN)),
|
||||
),
|
||||
HomeAssistantSpookSensorEntityDescription(
|
||||
key=Platform.COVER,
|
||||
translation_key="homeassistant_cover",
|
||||
@@ -237,6 +250,16 @@ SENSORS: tuple[HomeAssistantSpookSensorEntityDescription, ...] = (
|
||||
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
|
||||
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.FAN)),
|
||||
),
|
||||
HomeAssistantSpookSensorEntityDescription(
|
||||
key=group.DOMAIN,
|
||||
translation_key="homeassistant_group",
|
||||
entity_id="sensor.groups",
|
||||
icon="mdi:google-circles-communities",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
|
||||
value_fn=lambda hass: len(hass.states.async_entity_ids(group.DOMAIN)),
|
||||
),
|
||||
HomeAssistantSpookSensorEntityDescription(
|
||||
key=Platform.HUMIDIFIER,
|
||||
translation_key="homeassistant_humidifier",
|
||||
@@ -514,6 +537,16 @@ SENSORS: tuple[HomeAssistantSpookSensorEntityDescription, ...] = (
|
||||
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
|
||||
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.TIME)),
|
||||
),
|
||||
HomeAssistantSpookSensorEntityDescription(
|
||||
key=timer.DOMAIN,
|
||||
translation_key="homeassistant_timer",
|
||||
entity_id="sensor.timers",
|
||||
icon="mdi:timer-outline",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
|
||||
value_fn=lambda hass: len(hass.states.async_entity_ids(timer.DOMAIN)),
|
||||
),
|
||||
HomeAssistantSpookSensorEntityDescription(
|
||||
key=Platform.TODO,
|
||||
translation_key="homeassistant_todo",
|
||||
@@ -599,7 +632,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class HomeAssistantSpookSensorEntity(HomeAssistantSpookEntity, SensorEntity):
|
||||
"""Spook sensor providig Home Asistant information."""
|
||||
"""Spook sensor providing Home Assistant information."""
|
||||
|
||||
entity_description: HomeAssistantSpookSensorEntityDescription
|
||||
_unsub_debouncer: Callable[[], None] | None = None
|
||||
|
||||
@@ -1 +1,56 @@
|
||||
"""Spook - Your homie."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
# Home Assistant keeps the exposure settings, and the list of assistants that
|
||||
# have them, in this module. There is no public helper for either.
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
KNOWN_ASSISTANTS,
|
||||
async_expose_entity,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
CONF_ASSISTANTS = "assistants"
|
||||
|
||||
EXPOSURE_SERVICE_SCHEMA = {
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
vol.Required(CONF_ASSISTANTS): vol.All(cv.ensure_list, [vol.In(KNOWN_ASSISTANTS)]),
|
||||
}
|
||||
|
||||
|
||||
def async_set_voice_assistant_exposure(
|
||||
hass: HomeAssistant,
|
||||
call: ServiceCall,
|
||||
*,
|
||||
should_expose: bool,
|
||||
) -> None:
|
||||
"""Set whether entities are exposed to the given voice assistants.
|
||||
|
||||
Shared by the expose and unexpose actions, which differ only in what they
|
||||
set it to.
|
||||
"""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
# Checked up front, so an unknown entity halfway down the list does not
|
||||
# leave the ones before it already changed, with nothing saying which.
|
||||
entity_ids = call.data[ATTR_ENTITY_ID]
|
||||
for entity_id in entity_ids:
|
||||
if (
|
||||
hass.states.get(entity_id) is None
|
||||
and entity_registry.async_get(entity_id) is None
|
||||
):
|
||||
msg = f"Unknown entity: {entity_id}"
|
||||
raise HomeAssistantError(msg)
|
||||
|
||||
for entity_id in entity_ids:
|
||||
for assistant in call.data[CONF_ASSISTANTS]:
|
||||
async_expose_entity(hass, assistant, entity_id, should_expose=should_expose)
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -14,6 +14,7 @@ from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
)
|
||||
|
||||
from ....core_compat import async_update_any_device
|
||||
from ....services import AbstractSpookAdminService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -39,7 +40,8 @@ class SpookService(AbstractSpookAdminService):
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_id in call.data["device_id"]:
|
||||
device_registry.async_update_device(
|
||||
async_update_any_device(
|
||||
device_registry,
|
||||
device_id,
|
||||
area_id=call.data["area_id"],
|
||||
)
|
||||
|
||||
@@ -11,10 +11,10 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
config_validation as cv,
|
||||
label_registry as lr,
|
||||
)
|
||||
|
||||
from ....services import AbstractSpookAdminService
|
||||
from ..labels import async_check_labels_exist
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
@@ -32,15 +32,14 @@ class SpookService(AbstractSpookAdminService):
|
||||
|
||||
async def async_handle_service(self, call: ServiceCall) -> None:
|
||||
"""Handle the service call."""
|
||||
label_registry = lr.async_get(self.hass)
|
||||
for label_id in call.data["label_id"]:
|
||||
if not label_registry.async_get_label(label_id):
|
||||
msg = f"Label {label_id} not found"
|
||||
raise HomeAssistantError(msg)
|
||||
async_check_labels_exist(self.hass, call.data["label_id"])
|
||||
|
||||
area_registry = ar.async_get(self.hass)
|
||||
for area_id in call.data["area_id"]:
|
||||
if area_entry := area_registry.async_get_area(area_id):
|
||||
labels = area_entry.labels.copy()
|
||||
labels.update(call.data["label_id"])
|
||||
area_registry.async_update(area_id, labels=labels)
|
||||
if (area_entry := area_registry.async_get_area(area_id)) is None:
|
||||
msg = f"Area {area_id} not found"
|
||||
raise HomeAssistantError(msg)
|
||||
|
||||
labels = area_entry.labels.copy()
|
||||
labels.update(call.data["label_id"])
|
||||
area_registry.async_update(area_id, labels=labels)
|
||||
|
||||
@@ -11,10 +11,11 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
label_registry as lr,
|
||||
)
|
||||
|
||||
from ....core_compat import async_update_any_device
|
||||
from ....services import AbstractSpookAdminService
|
||||
from ..labels import async_check_labels_exist
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
@@ -32,15 +33,14 @@ class SpookService(AbstractSpookAdminService):
|
||||
|
||||
async def async_handle_service(self, call: ServiceCall) -> None:
|
||||
"""Handle the service call."""
|
||||
label_registry = lr.async_get(self.hass)
|
||||
for label_id in call.data["label_id"]:
|
||||
if not label_registry.async_get_label(label_id):
|
||||
msg = f"Label {label_id} not found"
|
||||
raise HomeAssistantError(msg)
|
||||
async_check_labels_exist(self.hass, call.data["label_id"])
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_id in call.data["device_id"]:
|
||||
if device_entry := device_registry.async_get(device_id):
|
||||
labels = device_entry.labels.copy()
|
||||
labels.update(call.data["label_id"])
|
||||
device_registry.async_update_device(device_id, labels=labels)
|
||||
if (device_entry := device_registry.async_get(device_id)) is None:
|
||||
msg = f"Device {device_id} not found"
|
||||
raise HomeAssistantError(msg)
|
||||
|
||||
labels = device_entry.labels.copy()
|
||||
labels.update(call.data["label_id"])
|
||||
async_update_any_device(device_registry, device_id, labels=labels)
|
||||
|
||||
@@ -11,10 +11,10 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_registry as er,
|
||||
label_registry as lr,
|
||||
)
|
||||
|
||||
from ....services import AbstractSpookAdminService
|
||||
from ..labels import async_check_labels_exist
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
@@ -32,15 +32,14 @@ class SpookService(AbstractSpookAdminService):
|
||||
|
||||
async def async_handle_service(self, call: ServiceCall) -> None:
|
||||
"""Handle the service call."""
|
||||
label_registry = lr.async_get(self.hass)
|
||||
for label_id in call.data["label_id"]:
|
||||
if not label_registry.async_get_label(label_id):
|
||||
msg = f"Label {label_id} not found"
|
||||
raise HomeAssistantError(msg)
|
||||
async_check_labels_exist(self.hass, call.data["label_id"])
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
for entity_id in call.data["entity_id"]:
|
||||
if entity_entry := entity_registry.async_get(entity_id):
|
||||
labels = entity_entry.labels.copy()
|
||||
labels.update(call.data["label_id"])
|
||||
entity_registry.async_update_entity(entity_id, labels=labels)
|
||||
if (entity_entry := entity_registry.async_get(entity_id)) is None:
|
||||
msg = f"Entity {entity_id} not found"
|
||||
raise HomeAssistantError(msg)
|
||||
|
||||
labels = entity_entry.labels.copy()
|
||||
labels.update(call.data["label_id"])
|
||||
entity_registry.async_update_entity(entity_id, labels=labels)
|
||||
|
||||
+24
-18
@@ -4,18 +4,31 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy import text
|
||||
|
||||
from homeassistant.components.homeassistant import DOMAIN
|
||||
from homeassistant.components.recorder import (
|
||||
get_instance,
|
||||
)
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.core import ServiceResponse, SupportsResponse
|
||||
from homeassistant.helpers.recorder import session_scope
|
||||
|
||||
from ....services import AbstractSpookService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
_RECORDED_ENTITY_IDS = text("SELECT DISTINCT(entity_id) FROM states_meta")
|
||||
|
||||
|
||||
def _recorded_entity_ids(hass: HomeAssistant) -> set[str]:
|
||||
"""Return every entity ID the recorder has ever written down.
|
||||
|
||||
Runs on the recorder's own thread, on the connection it already has.
|
||||
Opening a second engine here would put the whole query on the event loop,
|
||||
where a large or remote database stalls everything else in the house
|
||||
while it reads, and would leave a connection pool behind afterwards.
|
||||
"""
|
||||
with session_scope(hass=hass, read_only=True) as session:
|
||||
return {row[0] for row in session.execute(_RECORDED_ENTITY_IDS)}
|
||||
|
||||
|
||||
class SpookService(AbstractSpookService):
|
||||
@@ -27,21 +40,14 @@ class SpookService(AbstractSpookService):
|
||||
|
||||
async def async_handle_service(self, call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the service call."""
|
||||
query = text(
|
||||
"""
|
||||
SELECT DISTINCT(entity_id) FROM states_meta
|
||||
"""
|
||||
recorded = await get_instance(self.hass).async_add_executor_job(
|
||||
_recorded_entity_ids, self.hass
|
||||
)
|
||||
db_url = get_instance(self.hass).db_url
|
||||
engine = create_engine(db_url)
|
||||
with engine.connect() as conn:
|
||||
response = conn.execute(query)
|
||||
db_list = [e[0] for e in response]
|
||||
states_list = self.hass.states.async_entity_ids()
|
||||
compared_list = set(db_list).difference(states_list)
|
||||
orphaned = recorded.difference(self.hass.states.async_entity_ids())
|
||||
|
||||
if call.return_response:
|
||||
return {
|
||||
"count": len(compared_list),
|
||||
"entities": list(compared_list),
|
||||
"count": len(orphaned),
|
||||
"entities": list(orphaned),
|
||||
}
|
||||
return None
|
||||
|
||||
+3
-1
@@ -9,6 +9,7 @@ import voluptuous as vol
|
||||
from homeassistant.components.homeassistant import DOMAIN
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
|
||||
from ....core_compat import async_update_any_device
|
||||
from ....services import AbstractSpookAdminService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -28,7 +29,8 @@ class SpookService(AbstractSpookAdminService):
|
||||
"""Handle the service call."""
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_id in call.data["device_id"]:
|
||||
device_registry.async_update_device(
|
||||
async_update_any_device(
|
||||
device_registry,
|
||||
device_id,
|
||||
area_id=None,
|
||||
)
|
||||
|
||||
+15
-5
@@ -7,9 +7,14 @@ from typing import TYPE_CHECKING
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.homeassistant import DOMAIN
|
||||
from homeassistant.helpers import area_registry as ar, config_validation as cv
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
config_validation as cv,
|
||||
)
|
||||
|
||||
from ....services import AbstractSpookAdminService
|
||||
from ..labels import async_check_labels_exist
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
@@ -27,9 +32,14 @@ class SpookService(AbstractSpookAdminService):
|
||||
|
||||
async def async_handle_service(self, call: ServiceCall) -> None:
|
||||
"""Handle the service call."""
|
||||
async_check_labels_exist(self.hass, call.data["label_id"])
|
||||
|
||||
area_registry = ar.async_get(self.hass)
|
||||
for area_id in call.data["area_id"]:
|
||||
if area_entry := area_registry.async_get_area(area_id):
|
||||
labels = area_entry.labels.copy()
|
||||
labels.difference_update(call.data["label_id"])
|
||||
area_registry.async_update(area_id, labels=labels)
|
||||
if (area_entry := area_registry.async_get_area(area_id)) is None:
|
||||
msg = f"Area {area_id} not found"
|
||||
raise HomeAssistantError(msg)
|
||||
|
||||
labels = area_entry.labels.copy()
|
||||
labels.difference_update(call.data["label_id"])
|
||||
area_registry.async_update(area_id, labels=labels)
|
||||
|
||||
+16
-5
@@ -7,9 +7,15 @@ from typing import TYPE_CHECKING
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.homeassistant import DOMAIN
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
)
|
||||
|
||||
from ....core_compat import async_update_any_device
|
||||
from ....services import AbstractSpookAdminService
|
||||
from ..labels import async_check_labels_exist
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
@@ -27,9 +33,14 @@ class SpookService(AbstractSpookAdminService):
|
||||
|
||||
async def async_handle_service(self, call: ServiceCall) -> None:
|
||||
"""Handle the service call."""
|
||||
async_check_labels_exist(self.hass, call.data["label_id"])
|
||||
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device_id in call.data["device_id"]:
|
||||
if device_entry := device_registry.async_get(device_id):
|
||||
labels = device_entry.labels.copy()
|
||||
labels.difference_update(call.data["label_id"])
|
||||
device_registry.async_update_device(device_id, labels=labels)
|
||||
if (device_entry := device_registry.async_get(device_id)) is None:
|
||||
msg = f"Device {device_id} not found"
|
||||
raise HomeAssistantError(msg)
|
||||
|
||||
labels = device_entry.labels.copy()
|
||||
labels.difference_update(call.data["label_id"])
|
||||
async_update_any_device(device_registry, device_id, labels=labels)
|
||||
|
||||
+15
-5
@@ -7,9 +7,14 @@ from typing import TYPE_CHECKING
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.homeassistant import DOMAIN
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_registry as er,
|
||||
)
|
||||
|
||||
from ....services import AbstractSpookAdminService
|
||||
from ..labels import async_check_labels_exist
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
@@ -27,9 +32,14 @@ class SpookService(AbstractSpookAdminService):
|
||||
|
||||
async def async_handle_service(self, call: ServiceCall) -> None:
|
||||
"""Handle the service call."""
|
||||
async_check_labels_exist(self.hass, call.data["label_id"])
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
for entity_id in call.data["entity_id"]:
|
||||
if entity_entry := entity_registry.async_get(entity_id):
|
||||
labels = entity_entry.labels.copy()
|
||||
labels.difference_update(call.data["label_id"])
|
||||
entity_registry.async_update_entity(entity_id, labels=labels)
|
||||
if (entity_entry := entity_registry.async_get(entity_id)) is None:
|
||||
msg = f"Entity {entity_id} not found"
|
||||
raise HomeAssistantError(msg)
|
||||
|
||||
labels = entity_entry.labels.copy()
|
||||
labels.difference_update(call.data["label_id"])
|
||||
entity_registry.async_update_entity(entity_id, labels=labels)
|
||||
|
||||
Binary file not shown.
@@ -1 +1,81 @@
|
||||
"""Spook - Your homie."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components.input_number import InputNumber
|
||||
from homeassistant.core import ServiceCall
|
||||
|
||||
CONF_AMOUNT = "amount"
|
||||
CONF_CYCLE = "cycle"
|
||||
|
||||
# Steps are floats, so every comparison against a step boundary needs slack.
|
||||
_FLOAT_TOLERANCE = 1e-9
|
||||
|
||||
STEP_SERVICE_SCHEMA = {
|
||||
vol.Optional(CONF_AMOUNT): vol.Coerce(float),
|
||||
vol.Optional(CONF_CYCLE, default=False): cv.boolean,
|
||||
}
|
||||
|
||||
|
||||
def native_value_as_float(entity: InputNumber) -> float:
|
||||
"""Return the current value of an input number as a float."""
|
||||
try:
|
||||
return float(entity.native_value)
|
||||
except (TypeError, ValueError) as err:
|
||||
msg = f"Value {entity.native_value!r} for {entity.entity_id} is not a number"
|
||||
raise HomeAssistantError(msg) from err
|
||||
|
||||
|
||||
def step_amount(entity: InputNumber, call: ServiceCall) -> float:
|
||||
"""Return the requested amount, checked against the entity's step."""
|
||||
step = entity.native_step
|
||||
amount = call.data.get(CONF_AMOUNT, step)
|
||||
remainder = amount % step
|
||||
# Float modulo wraps around just short of the step, 0.3 % 0.1 is
|
||||
# 0.09999999999999998, so a remainder against either end is a clean multiple.
|
||||
if not (
|
||||
math.isclose(remainder, 0, rel_tol=0, abs_tol=_FLOAT_TOLERANCE)
|
||||
or math.isclose(remainder, step, rel_tol=0, abs_tol=_FLOAT_TOLERANCE)
|
||||
):
|
||||
msg = (
|
||||
f"Amount {amount} not valid for {entity.entity_id}, "
|
||||
f"it needs to be a multiple of {step}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
return amount
|
||||
|
||||
|
||||
def cycled_value(entity: InputNumber, value: float) -> float:
|
||||
"""Wrap a value around the entity's range.
|
||||
|
||||
Home Assistant cycles a select by taking the new index modulo the number
|
||||
of options. The same thing here means the positions on the step grid from
|
||||
the minimum to the maximum, so overshooting one end carries on from the
|
||||
other rather than landing on it. Stepping one past the maximum reaches the
|
||||
minimum, and stepping five past it reaches the fifth value up.
|
||||
"""
|
||||
minimum = entity.native_min_value
|
||||
step = entity.native_step
|
||||
|
||||
# Floored, because the step does not have to divide the range. Rounding
|
||||
# would invent a slot above the maximum: a step of 0.6 across 0 to 1 would
|
||||
# offer 1.2, and setting that raises. The tolerance keeps a quotient that
|
||||
# is a whole number in spirit, like 10 steps of 0.1, from flooring down.
|
||||
quotient = (entity.native_max_value - minimum) / step
|
||||
slots = math.floor(quotient + _FLOAT_TOLERANCE) + 1
|
||||
|
||||
# Counted in whole steps, so float drift cannot push the result into the
|
||||
# neighbouring slot on the way through the modulo.
|
||||
offset = round((value - minimum) / step)
|
||||
|
||||
return minimum + (offset % slots) * step
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -2,14 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.input_number import DOMAIN, InputNumber
|
||||
|
||||
from ....services import AbstractSpookEntityComponentService, ReplaceExistingService
|
||||
from . import (
|
||||
CONF_CYCLE,
|
||||
STEP_SERVICE_SCHEMA,
|
||||
cycled_value,
|
||||
native_value_as_float,
|
||||
step_amount,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
@@ -20,12 +24,13 @@ class SpookService(
|
||||
):
|
||||
"""Input number entity service, decrease value by a single step.
|
||||
|
||||
It override the built-in increment service to allow for a custom amount.
|
||||
It overrides the built-in decrement service to allow for a custom amount,
|
||||
and to cycle around the range instead of stopping at the end of it.
|
||||
"""
|
||||
|
||||
domain = DOMAIN
|
||||
service = "decrement"
|
||||
schema = {vol.Optional("amount"): vol.Coerce(float)}
|
||||
schema = STEP_SERVICE_SCHEMA
|
||||
|
||||
async def async_handle_service(
|
||||
self,
|
||||
@@ -33,18 +38,16 @@ class SpookService(
|
||||
call: ServiceCall,
|
||||
) -> None:
|
||||
"""Handle the service call."""
|
||||
# pylint: disable=protected-access
|
||||
amount = call.data.get("amount", entity._step) # noqa: SLF001
|
||||
if not math.isclose(amount % entity._step, 0, abs_tol=1e-9): # noqa: SLF001
|
||||
msg = (
|
||||
f"Amount {amount} not valid for {entity.entity_id}, "
|
||||
f"it needs to be a multiple of {entity._step}", # noqa: SLF001
|
||||
)
|
||||
raise ValueError(msg)
|
||||
new_value = native_value_as_float(entity) - step_amount(entity, call)
|
||||
|
||||
await entity.async_set_value(
|
||||
max(
|
||||
entity._current_value - amount, # noqa: SLF001
|
||||
entity._minimum, # noqa: SLF001
|
||||
),
|
||||
)
|
||||
if call.data[CONF_CYCLE]:
|
||||
# A negative amount moves the other way, so either end can be the
|
||||
# one that gets crossed.
|
||||
if not (entity.native_min_value <= new_value <= entity.native_max_value):
|
||||
new_value = cycled_value(entity, new_value)
|
||||
elif new_value < entity.native_min_value:
|
||||
# Without cycling, only the end this action moves towards is
|
||||
# clamped, which is what it did before cycling existed.
|
||||
new_value = entity.native_min_value
|
||||
|
||||
await entity.async_set_native_value(new_value)
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.input_number import DOMAIN, InputNumber
|
||||
|
||||
from ....services import AbstractSpookEntityComponentService, ReplaceExistingService
|
||||
from . import (
|
||||
CONF_CYCLE,
|
||||
STEP_SERVICE_SCHEMA,
|
||||
cycled_value,
|
||||
native_value_as_float,
|
||||
step_amount,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
@@ -20,12 +24,13 @@ class SpookService(
|
||||
):
|
||||
"""Input number entity service, increase value by a single step.
|
||||
|
||||
It override the built-in increment service to allow for a custom amount.
|
||||
It overrides the built-in increment service to allow for a custom amount,
|
||||
and to cycle around the range instead of stopping at the end of it.
|
||||
"""
|
||||
|
||||
domain = DOMAIN
|
||||
service = "increment"
|
||||
schema = {vol.Optional("amount"): vol.Coerce(float)}
|
||||
schema = STEP_SERVICE_SCHEMA
|
||||
|
||||
async def async_handle_service(
|
||||
self,
|
||||
@@ -33,18 +38,16 @@ class SpookService(
|
||||
call: ServiceCall,
|
||||
) -> None:
|
||||
"""Handle the service call."""
|
||||
# pylint: disable=protected-access
|
||||
amount = call.data.get("amount", entity._step) # noqa: SLF001
|
||||
if not math.isclose(amount % entity._step, 0, abs_tol=1e-9): # noqa: SLF001
|
||||
msg = (
|
||||
f"Amount {amount} not valid for {entity.entity_id}, "
|
||||
f"it needs to be a multiple of {entity._step}", # noqa: SLF001
|
||||
)
|
||||
raise ValueError(msg)
|
||||
new_value = native_value_as_float(entity) + step_amount(entity, call)
|
||||
|
||||
await entity.async_set_value(
|
||||
min(
|
||||
entity._current_value + amount, # noqa: SLF001
|
||||
entity._maximum, # noqa: SLF001
|
||||
),
|
||||
)
|
||||
if call.data[CONF_CYCLE]:
|
||||
# A negative amount moves the other way, so either end can be the
|
||||
# one that gets crossed.
|
||||
if not (entity.native_min_value <= new_value <= entity.native_max_value):
|
||||
new_value = cycled_value(entity, new_value)
|
||||
elif new_value > entity.native_max_value:
|
||||
# Without cycling, only the end this action moves towards is
|
||||
# clamped, which is what it did before cycling existed.
|
||||
new_value = entity.native_max_value
|
||||
|
||||
await entity.async_set_native_value(new_value)
|
||||
|
||||
@@ -24,5 +24,4 @@ class SpookService(AbstractSpookEntityComponentService[InputNumber]):
|
||||
call: ServiceCall, # noqa: ARG002
|
||||
) -> None:
|
||||
"""Handle the service call."""
|
||||
# pylint: disable-next=protected-access
|
||||
await entity.async_set_value(entity._maximum) # noqa: SLF001
|
||||
await entity.async_set_native_value(entity.native_max_value)
|
||||
|
||||
@@ -24,5 +24,4 @@ class SpookService(AbstractSpookEntityComponentService[InputNumber]):
|
||||
call: ServiceCall, # noqa: ARG002
|
||||
) -> None:
|
||||
"""Handle the service call."""
|
||||
# pylint: disable-next=protected-access
|
||||
await entity.async_set_value(entity._minimum) # noqa: SLF001
|
||||
await entity.async_set_native_value(entity.native_min_value)
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.lovelace import DOMAIN
|
||||
from homeassistant.components.lovelace.const import ConfigNotFound
|
||||
from homeassistant.const import (
|
||||
EVENT_COMPONENT_LOADED,
|
||||
EVENT_LOVELACE_UPDATED,
|
||||
@@ -15,12 +14,11 @@ from homeassistant.core import Event, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ....const import LOGGER
|
||||
from ....entity_filtering import (
|
||||
async_filter_known_entity_ids,
|
||||
async_get_all_entity_ids,
|
||||
split_comma_separated_entity_ids,
|
||||
)
|
||||
from ....dashboard_extraction import extract_entities_from_dashboard_node
|
||||
from ....entity_filtering import async_filter_known_entity_ids, async_get_all_entity_ids
|
||||
from ....entity_suggestions import async_describe_unknown_entities
|
||||
from ....repairs import AbstractSpookRepair
|
||||
from ..dashboards import async_dashboard_configs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
@@ -81,13 +79,11 @@ class SpookRepair(AbstractSpookRepair):
|
||||
|
||||
# Loop over all dashboards and check if there are unknown entities
|
||||
# referenced in the dashboards.
|
||||
for dashboard in self._dashboards.values():
|
||||
url_path = dashboard.url_path or "lovelace"
|
||||
async for dashboard, url_path, config in async_dashboard_configs(
|
||||
self._dashboards
|
||||
):
|
||||
self.possible_issue_ids.add(url_path)
|
||||
try:
|
||||
config = await dashboard.async_load(force=False)
|
||||
except ConfigNotFound:
|
||||
LOGGER.debug("Config for dashboard %s not found, skipping", url_path)
|
||||
if config is None:
|
||||
continue
|
||||
|
||||
extracted_entities = self.__async_extract_entities(config)
|
||||
@@ -108,8 +104,8 @@ class SpookRepair(AbstractSpookRepair):
|
||||
self.async_create_issue(
|
||||
issue_id=url_path,
|
||||
translation_placeholders={
|
||||
"entities": "\n".join(
|
||||
f"- `{entity_id}`" for entity_id in sorted(unknown_entities)
|
||||
"entities": async_describe_unknown_entities(
|
||||
self.hass, sorted(unknown_entities)
|
||||
),
|
||||
"dashboard": title,
|
||||
"edit": f"/{url_path}/{first_view_path}?edit=1",
|
||||
@@ -126,248 +122,13 @@ class SpookRepair(AbstractSpookRepair):
|
||||
|
||||
@callback
|
||||
def __async_extract_entities(self, config: dict[str, Any]) -> dict[str, int | str]:
|
||||
"""Extract entities from a dashboard config."""
|
||||
"""Extract entities from a dashboard config, keyed by their view path."""
|
||||
entities: dict[str, int | str] = {}
|
||||
if isinstance(config, dict) and (views := config.get("views")):
|
||||
for view_index, view in enumerate(views):
|
||||
if not isinstance(view, dict):
|
||||
continue
|
||||
view_path: int | str = view.get("path") or view_index
|
||||
for entity_id_raw in self.__async_extract_entities_from_view(view):
|
||||
for entity_id in split_comma_separated_entity_ids(entity_id_raw):
|
||||
if entity_id not in entities:
|
||||
entities[entity_id] = view_path
|
||||
return entities
|
||||
|
||||
@callback
|
||||
def __async_extract_entities_from_view(self, config: dict[Any]) -> set[str]:
|
||||
"""Extract entities from a view config."""
|
||||
entities = set()
|
||||
if badges := config.get("badges"):
|
||||
for badge in badges:
|
||||
entities.update(self.__async_extract_entities_from_badge(badge))
|
||||
if cards := config.get("cards"):
|
||||
for card in cards:
|
||||
entities.update(self.__async_extract_entities_from_card(card))
|
||||
if sections := config.get("sections"):
|
||||
for section in sections:
|
||||
entities.update(self.__async_extract_entities_from_section(section))
|
||||
return entities
|
||||
|
||||
@callback
|
||||
def __async_extract_entities_from_section(self, config: dict[Any]) -> set[str]:
|
||||
"""Extract entities from a section config."""
|
||||
entities = set()
|
||||
if cards := config.get("cards"):
|
||||
for card in cards:
|
||||
entities.update(self.__async_extract_entities_from_card(card))
|
||||
return entities
|
||||
|
||||
@callback
|
||||
def __async_extract_common(self, config: dict[str, Any] | str) -> set[str]:
|
||||
"""Extract entities from common dashboard config."""
|
||||
entities: set[str] = set()
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return entities
|
||||
|
||||
for key in ("camera_image", "entity", "entities", "entity_id"):
|
||||
if entity := config.get(key):
|
||||
if isinstance(entity, str):
|
||||
entities.add(entity)
|
||||
if isinstance(entity, list):
|
||||
for item in entity:
|
||||
if isinstance(item, str):
|
||||
entities.add(item)
|
||||
elif (
|
||||
isinstance(item, dict)
|
||||
and "entity" in item
|
||||
and isinstance(item["entity"], str)
|
||||
):
|
||||
entities.add(item["entity"])
|
||||
if (
|
||||
isinstance(entity, dict)
|
||||
and "entity" in entity
|
||||
and isinstance(entity["entity"], str)
|
||||
):
|
||||
entities.add(entity["entity"])
|
||||
return entities
|
||||
|
||||
@callback
|
||||
def __async_extract_entities_from_badge(
|
||||
self,
|
||||
config: dict[str, Any] | str,
|
||||
) -> set[str]:
|
||||
"""Extract entities from a dashboard badge config."""
|
||||
if isinstance(config, str):
|
||||
return {config}
|
||||
if isinstance(config, dict):
|
||||
if (entity_id := config.get("entity")) and isinstance(entity_id, str):
|
||||
return {config["entity"]}
|
||||
if (entities := config.get("entities")) and isinstance(entities, list):
|
||||
extracted = []
|
||||
for entity in entities:
|
||||
if isinstance(entity, str):
|
||||
extracted.append(entity)
|
||||
elif (
|
||||
isinstance(entity, dict)
|
||||
and "entity" in entity
|
||||
and isinstance(entity["entity"], str)
|
||||
):
|
||||
extracted.append(entity["entity"])
|
||||
return set(extracted)
|
||||
return set()
|
||||
|
||||
@callback
|
||||
# pylint: disable-next=too-many-branches
|
||||
def __async_extract_entities_from_card( # noqa: C901, PLR0912
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
) -> set[str]:
|
||||
"""Extract entities from a dashboard card config."""
|
||||
if not isinstance(config, dict):
|
||||
return set()
|
||||
|
||||
entities = self.__async_extract_common(config)
|
||||
entities.update(self.__async_extract_entities_from_actions(config))
|
||||
|
||||
if condition := config.get("condition"):
|
||||
entities.update(
|
||||
self.__async_extract_entities_from_condition(condition),
|
||||
)
|
||||
|
||||
if card := config.get("card"):
|
||||
entities.update(self.__async_extract_entities_from_card(card))
|
||||
|
||||
if cards := config.get("cards"):
|
||||
for card in cards:
|
||||
entities.update(self.__async_extract_entities_from_card(card))
|
||||
|
||||
for key in ("header", "footer"):
|
||||
if header_footer := config.get(key):
|
||||
entities.update(
|
||||
self.__async_extract_entities_from_header_footer(header_footer),
|
||||
)
|
||||
|
||||
if elements := config.get("elements"):
|
||||
for element in elements:
|
||||
entities.update(self.__async_extract_entities_from_element(element))
|
||||
|
||||
# Mushroom
|
||||
if chips := config.get("chips"):
|
||||
for chip in chips:
|
||||
entities.update(self.__async_extract_entities_from_mushroom_chip(chip))
|
||||
|
||||
# Heading card
|
||||
if badges := config.get("badges"):
|
||||
for badge in badges:
|
||||
if isinstance(badge, dict):
|
||||
entities.update(self.__async_extract_common(badge))
|
||||
entities.update(self.__async_extract_entities_from_actions(badge))
|
||||
|
||||
visibility = config.get("visibility")
|
||||
if isinstance(visibility, list):
|
||||
for condition in visibility:
|
||||
if isinstance(condition, dict):
|
||||
entities.update(
|
||||
self.__async_extract_entities_from_condition(condition)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
@callback
|
||||
def __async_extract_entities_from_actions(self, config: dict[str, Any]) -> set[str]:
|
||||
"""Extract entities from a dashboard config containing actions."""
|
||||
entities = set()
|
||||
for key in (
|
||||
"tap_action",
|
||||
"hold_action",
|
||||
"double_tap_action",
|
||||
"subtitle_tap_action",
|
||||
):
|
||||
if isinstance(config, dict) and (action := config.get(key)):
|
||||
entities.update(self.__async_extract_entities_from_action(action))
|
||||
return entities
|
||||
|
||||
@callback
|
||||
def __async_extract_entities_from_action(self, config: dict[str, Any]) -> set[str]:
|
||||
"""Extract entities from a dashboard action config."""
|
||||
entities = set()
|
||||
for key in ("service_data", "target"):
|
||||
if (
|
||||
isinstance(config, dict)
|
||||
and (target := config.get(key))
|
||||
and isinstance(target, dict)
|
||||
and (entity_id := target.get("entity_id"))
|
||||
):
|
||||
if isinstance(entity_id, str):
|
||||
entities.add(entity_id)
|
||||
if isinstance(entity_id, list):
|
||||
entities.update(entity_id)
|
||||
return entities
|
||||
|
||||
@callback
|
||||
def __async_extract_entities_from_condition(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
) -> set[str]:
|
||||
"""Extract entities from a dashboard element config."""
|
||||
if "entity" in config and isinstance(config["entity"], str):
|
||||
return {config["entity"]}
|
||||
return set()
|
||||
|
||||
@callback
|
||||
def __async_extract_entities_from_element(self, config: dict[str, Any]) -> set[str]:
|
||||
"""Extract entities from a dashboard element config."""
|
||||
if not isinstance(config, dict):
|
||||
return set()
|
||||
|
||||
entities = self.__async_extract_common(config)
|
||||
entities.update(self.__async_extract_entities_from_actions(config))
|
||||
entities.update(self.__async_extract_entities_from_action(config))
|
||||
|
||||
if conditions := config.get("conditions"):
|
||||
for condition in conditions:
|
||||
entities.update(self.__async_extract_entities_from_condition(condition))
|
||||
|
||||
if elements := config.get("elements"):
|
||||
for element in elements:
|
||||
entities.update(self.__async_extract_entities_from_element(element))
|
||||
|
||||
visibility = config.get("visibility")
|
||||
if isinstance(visibility, list):
|
||||
for condition in visibility:
|
||||
if isinstance(condition, dict):
|
||||
entities.update(
|
||||
self.__async_extract_entities_from_condition(condition)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
@callback
|
||||
def __async_extract_entities_from_header_footer(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
) -> set[str]:
|
||||
"""Extract entities from a dashboard haeder footer config."""
|
||||
entities = self.__async_extract_common(config)
|
||||
entities.update(self.__async_extract_entities_from_actions(config))
|
||||
return entities
|
||||
|
||||
@callback
|
||||
def __async_extract_entities_from_mushroom_chip(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
) -> set[str]:
|
||||
"""Extract entities from mushroom chips."""
|
||||
if not isinstance(config, dict):
|
||||
return set()
|
||||
entities = self.__async_extract_common(config)
|
||||
if chip := config.get("chip"):
|
||||
entities.update(
|
||||
self.__async_extract_entities_from_mushroom_chip(chip),
|
||||
)
|
||||
if conditions := config.get("conditions"):
|
||||
for condition in conditions:
|
||||
entities.update(self.__async_extract_entities_from_condition(condition))
|
||||
for entity_id in extract_entities_from_dashboard_node(view):
|
||||
entities.setdefault(entity_id, view_path)
|
||||
return entities
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user