429 files

This commit is contained in:
Home Assistant Version Control
2026-08-30 02:43:31 +00:00
parent 472c19bceb
commit 963cce81a7
566 changed files with 50311 additions and 47616 deletions
@@ -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,
@@ -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,
)
@@ -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
@@ -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,
},
@@ -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)
@@ -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)
@@ -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
@@ -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,
)
@@ -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)
@@ -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)
@@ -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)
@@ -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
@@ -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)
@@ -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

Some files were not shown because too many files have changed in this diff Show More