291 files
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import automation
|
||||
@@ -9,7 +10,8 @@ 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,
|
||||
async_raw_config_references,
|
||||
core_references,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from . import AbstractSpookAutomationReferencesRepair
|
||||
@@ -42,16 +44,17 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown area IDs referenced by ``entity``."""
|
||||
area_ids = set(entity.referenced_areas)
|
||||
area_ids = core_references(entity, "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)
|
||||
# Templates that look up an area that is not there are read too.
|
||||
references = partial(async_raw_config_references, self.hass, kind="areas")
|
||||
area_ids.update(references(raw_config))
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
area_ids -= only_in_disabled_steps(
|
||||
raw_config, lambda found: extract_targets_from_config(found).area_ids
|
||||
)
|
||||
# Read the same way, or a template there still counts.
|
||||
area_ids -= only_in_disabled_steps(raw_config, references)
|
||||
|
||||
return async_filter_known_area_ids(
|
||||
self.hass,
|
||||
|
||||
@@ -9,7 +9,9 @@ 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 (
|
||||
core_references,
|
||||
extract_targets_from_config,
|
||||
names_given_to_templates,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from ....template_extraction import extract_device_ids_from_config
|
||||
@@ -74,7 +76,7 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown device IDs referenced by ``entity``."""
|
||||
device_ids = set(entity.referenced_devices)
|
||||
device_ids = core_references(entity, "devices")
|
||||
|
||||
if hasattr(entity, "raw_config") and entity.raw_config:
|
||||
# Also walk the raw configuration; the built-in extraction misses
|
||||
@@ -92,14 +94,18 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
entity.raw_config.get("triggers")
|
||||
)
|
||||
)
|
||||
# Devices referenced via device_entities() in templates.
|
||||
device_ids.update(extract_device_ids_from_config(entity.raw_config))
|
||||
# Devices referenced through the device functions in templates.
|
||||
# The variables and fields it gives its templates hide a call.
|
||||
shadowed = names_given_to_templates(entity.raw_config)
|
||||
device_ids.update(
|
||||
extract_device_ids_from_config(entity.raw_config, shadowed)
|
||||
)
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
device_ids -= only_in_disabled_steps(
|
||||
entity.raw_config,
|
||||
lambda found: (
|
||||
extract_targets_from_config(found).device_ids
|
||||
| extract_device_ids_from_config(found)
|
||||
| extract_device_ids_from_config(found, shadowed)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+99
-68
@@ -11,13 +11,19 @@ from homeassistant.helpers import entity_registry as er
|
||||
from ....action_extraction import (
|
||||
async_extract_entities_from_action_config,
|
||||
async_extract_entities_from_value,
|
||||
async_unknown_entities_named,
|
||||
)
|
||||
from ....entity_filtering import async_get_all_entity_ids, async_get_all_services
|
||||
from ....reference_extraction import without_disabled_steps
|
||||
from ....reference_extraction import (
|
||||
VALUE_KEYS,
|
||||
core_references,
|
||||
event_payload_keys_to_leave_alone,
|
||||
names_given_to_templates,
|
||||
threshold_entities,
|
||||
without_never_rendered,
|
||||
)
|
||||
from ....template_extraction import (
|
||||
KNOWN_DOMAINS,
|
||||
async_extract_entities_from_config,
|
||||
async_filter_known_entity_ids_with_templates,
|
||||
)
|
||||
from . import AbstractSpookAutomationReferencesRepair
|
||||
|
||||
@@ -43,13 +49,21 @@ async def extract_template_entities_from_automation_entity(
|
||||
else:
|
||||
return set()
|
||||
|
||||
return await async_extract_entities_from_config(hass, config, known_services)
|
||||
# A description, a name or a field example is shown, never rendered: an
|
||||
# entity in an example template there is not one the automation uses.
|
||||
return await async_extract_entities_from_config(
|
||||
hass,
|
||||
without_never_rendered(config),
|
||||
known_services,
|
||||
names_given_to_templates(config),
|
||||
)
|
||||
|
||||
|
||||
async def extract_entities_from_automation_config(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any],
|
||||
known_services: set[str] | None = None,
|
||||
shadowed: frozenset[str] | None = None,
|
||||
) -> set[str]:
|
||||
"""Extract entity IDs from automation configuration.
|
||||
|
||||
@@ -57,6 +71,10 @@ async def extract_entities_from_automation_config(
|
||||
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`.
|
||||
|
||||
The names the automation gives its templates, like a variable called
|
||||
`states`, are taken from all of it here, before walking its parts,
|
||||
unless ``shadowed`` already holds them.
|
||||
"""
|
||||
entities = set()
|
||||
|
||||
@@ -65,13 +83,15 @@ async def extract_entities_from_automation_config(
|
||||
|
||||
if known_services is None:
|
||||
known_services = async_get_all_services(hass)
|
||||
if shadowed is None:
|
||||
shadowed = names_given_to_templates(config)
|
||||
|
||||
# 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], known_services
|
||||
hass, config[key], known_services, shadowed=shadowed
|
||||
)
|
||||
)
|
||||
|
||||
@@ -80,7 +100,7 @@ async def extract_entities_from_automation_config(
|
||||
if key in config:
|
||||
entities.update(
|
||||
await extract_entities_from_condition_config(
|
||||
hass, config[key], known_services
|
||||
hass, config[key], known_services, shadowed=shadowed
|
||||
)
|
||||
)
|
||||
|
||||
@@ -89,7 +109,10 @@ async def extract_entities_from_automation_config(
|
||||
if key in config:
|
||||
entities.update(
|
||||
await async_extract_entities_from_action_config(
|
||||
hass, config[key], known_services=known_services
|
||||
hass,
|
||||
config[key],
|
||||
known_services=known_services,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -100,31 +123,36 @@ async def _entities_from_reference_fields(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any],
|
||||
known_services: set[str],
|
||||
*,
|
||||
in_values: bool = False,
|
||||
shadowed: frozenset[str] = frozenset(),
|
||||
) -> 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.
|
||||
one, and it is read exactly like the others. So is the entity a trigger
|
||||
or condition takes as its threshold, unless the dictionary sits below a
|
||||
key that holds values: event data that only looks like one is still event
|
||||
data.
|
||||
"""
|
||||
entities = set()
|
||||
entities = set() if in_values else threshold_entities(config)
|
||||
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
|
||||
hass, config[key], known_services=known_services, shadowed=shadowed
|
||||
)
|
||||
)
|
||||
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,
|
||||
known_services: set[str] | None = None,
|
||||
*,
|
||||
_in_values: bool = False,
|
||||
shadowed: frozenset[str] = frozenset(),
|
||||
) -> set[str]:
|
||||
"""Extract entity IDs from trigger configuration."""
|
||||
entities = set()
|
||||
@@ -138,16 +166,26 @@ async def extract_entities_from_trigger_config(
|
||||
if isinstance(config, list):
|
||||
for item in config:
|
||||
entities.update(
|
||||
await extract_entities_from_trigger_config(hass, item, known_services)
|
||||
await extract_entities_from_trigger_config(
|
||||
hass,
|
||||
item,
|
||||
known_services,
|
||||
_in_values=_in_values,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return entities
|
||||
|
||||
entities.update(await _entities_from_reference_fields(hass, config, known_services))
|
||||
entities.update(
|
||||
await _entities_from_reference_fields(
|
||||
hass, config, known_services, in_values=_in_values, shadowed=shadowed
|
||||
)
|
||||
)
|
||||
|
||||
payload_keys = _payload_keys_to_leave_alone(config)
|
||||
payload_keys = event_payload_keys_to_leave_alone(config)
|
||||
|
||||
# Extract from nested configs
|
||||
for key, value in config.items():
|
||||
@@ -156,42 +194,18 @@ async def extract_entities_from_trigger_config(
|
||||
|
||||
if isinstance(value, (dict, list)):
|
||||
entities.update(
|
||||
await extract_entities_from_trigger_config(hass, value, known_services)
|
||||
await extract_entities_from_trigger_config(
|
||||
hass,
|
||||
value,
|
||||
known_services,
|
||||
_in_values=_in_values or key in VALUE_KEYS,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -224,6 +238,9 @@ async def extract_entities_from_condition_config(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, Any] | list,
|
||||
known_services: set[str] | None = None,
|
||||
*,
|
||||
_in_values: bool = False,
|
||||
shadowed: frozenset[str] = frozenset(),
|
||||
) -> set[str]:
|
||||
"""Extract entity IDs from condition configuration."""
|
||||
entities = set()
|
||||
@@ -237,21 +254,35 @@ async def extract_entities_from_condition_config(
|
||||
if isinstance(config, list):
|
||||
for item in config:
|
||||
entities.update(
|
||||
await extract_entities_from_condition_config(hass, item, known_services)
|
||||
await extract_entities_from_condition_config(
|
||||
hass,
|
||||
item,
|
||||
known_services,
|
||||
_in_values=_in_values,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
)
|
||||
return entities
|
||||
|
||||
if not isinstance(config, dict):
|
||||
return entities
|
||||
|
||||
entities.update(await _entities_from_reference_fields(hass, config, known_services))
|
||||
entities.update(
|
||||
await _entities_from_reference_fields(
|
||||
hass, config, known_services, in_values=_in_values, shadowed=shadowed
|
||||
)
|
||||
)
|
||||
|
||||
# Extract from nested configs
|
||||
for value in config.values():
|
||||
for key, value in config.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
entities.update(
|
||||
await extract_entities_from_condition_config(
|
||||
hass, value, known_services
|
||||
hass,
|
||||
value,
|
||||
known_services,
|
||||
_in_values=_in_values or key in VALUE_KEYS,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -292,24 +323,30 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
)
|
||||
self._known_services = async_get_all_services(self.hass)
|
||||
|
||||
async def _async_named_in(self, config: dict[str, Any]) -> set[str]:
|
||||
async def _async_named_in(
|
||||
self, config: dict[str, Any], shadowed: frozenset[str]
|
||||
) -> set[str]:
|
||||
"""Return the entities a configuration names, the way this repair reads it.
|
||||
|
||||
The same reading the report is built from, structure and templates
|
||||
alike, so comparing it with and without the disabled steps leaves out
|
||||
exactly what only those name.
|
||||
exactly what only those name. ``shadowed`` comes from the whole
|
||||
configuration, as `config` can be one without its disabled steps.
|
||||
"""
|
||||
named = await extract_entities_from_automation_config(
|
||||
self.hass, config, self._known_services
|
||||
self.hass, config, self._known_services, shadowed
|
||||
)
|
||||
named |= await async_extract_entities_from_config(
|
||||
self.hass, config, self._known_services
|
||||
self.hass,
|
||||
without_never_rendered(config),
|
||||
self._known_services,
|
||||
shadowed,
|
||||
)
|
||||
return named
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown entity IDs referenced by ``entity`` (incl. templates)."""
|
||||
all_entities = set(entity.referenced_entities)
|
||||
all_entities = core_references(entity, "entities")
|
||||
|
||||
# Also extract entities directly from raw configuration if available
|
||||
if hasattr(entity, "raw_config") and entity.raw_config:
|
||||
@@ -330,18 +367,12 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
)
|
||||
)
|
||||
|
||||
# Home Assistant's own list includes disabled steps, triggers and
|
||||
# conditions too. Something parked that way does nothing, so what only
|
||||
# it names is left out of the report: whatever this repair finds in
|
||||
# the configuration, and no longer finds once those are pruned.
|
||||
if isinstance(raw_config := getattr(entity, "raw_config", None), dict):
|
||||
named = await self._async_named_in(raw_config)
|
||||
still_named = await self._async_named_in(without_disabled_steps(raw_config))
|
||||
all_entities -= named - still_named
|
||||
|
||||
return await async_filter_known_entity_ids_with_templates(
|
||||
raw_config = getattr(entity, "raw_config", None)
|
||||
return await async_unknown_entities_named(
|
||||
self.hass,
|
||||
entity_ids=all_entities,
|
||||
all_entities,
|
||||
raw_config,
|
||||
named_in=self._async_named_in,
|
||||
known_entity_ids=self._known_entity_ids,
|
||||
known_services=self._known_services,
|
||||
)
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.helpers import floor_registry as fr
|
||||
from homeassistant.helpers import area_registry as ar, 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,
|
||||
async_raw_config_references,
|
||||
core_references,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from . import AbstractSpookAutomationReferencesRepair
|
||||
@@ -23,7 +25,10 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
|
||||
domain = automation.DOMAIN
|
||||
repair = "automation_unknown_floor_references"
|
||||
# Templates look floors up through areas as well, so a renamed area
|
||||
# can change what they find.
|
||||
inspect_events = {
|
||||
ar.EVENT_AREA_REGISTRY_UPDATED,
|
||||
fr.EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
}
|
||||
inspect_on_reload = True
|
||||
@@ -41,16 +46,17 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown floor IDs referenced by ``entity``."""
|
||||
floor_ids = set(entity.referenced_floors)
|
||||
floor_ids = core_references(entity, "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)
|
||||
# Templates that look up a floor that is not there are read too.
|
||||
references = partial(async_raw_config_references, self.hass, kind="floors")
|
||||
floor_ids.update(references(raw_config))
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
floor_ids -= only_in_disabled_steps(
|
||||
raw_config, lambda found: extract_targets_from_config(found).floor_ids
|
||||
)
|
||||
# Read the same way, or a template there still counts.
|
||||
floor_ids -= only_in_disabled_steps(raw_config, references)
|
||||
|
||||
return async_filter_known_floor_ids(
|
||||
self.hass,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import automation
|
||||
@@ -9,7 +10,8 @@ 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,
|
||||
async_raw_config_references,
|
||||
core_references,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from . import AbstractSpookAutomationReferencesRepair
|
||||
@@ -41,16 +43,17 @@ class SpookRepair(AbstractSpookAutomationReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown label IDs referenced by ``entity``."""
|
||||
label_ids = set(entity.referenced_labels)
|
||||
label_ids = core_references(entity, "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)
|
||||
# Templates that look up a label that is not there are read too.
|
||||
references = partial(async_raw_config_references, self.hass, kind="labels")
|
||||
label_ids.update(references(raw_config))
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
label_ids -= only_in_disabled_steps(
|
||||
raw_config, lambda found: extract_targets_from_config(found).label_ids
|
||||
)
|
||||
# Read the same way, or a template there still counts.
|
||||
label_ids -= only_in_disabled_steps(raw_config, references)
|
||||
|
||||
return async_filter_known_label_ids(
|
||||
self.hass,
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.cloud.const import DOMAIN as CLOUD_DOMAIN
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from ...const import DOMAIN
|
||||
@@ -15,6 +14,12 @@ if TYPE_CHECKING:
|
||||
|
||||
from homeassistant.components.cloud.client import CloudClient
|
||||
|
||||
# Spelled out rather than imported. Importing anything from the cloud
|
||||
# integration runs its package first, and that pulls in Alexa, Google
|
||||
# Assistant and camera with it, on the single import thread, at startup,
|
||||
# for a house that may not use the cloud at all. #1898.
|
||||
CLOUD_DOMAIN = "cloud"
|
||||
|
||||
|
||||
class HomeAssistantCloudSpookEntity(SpookEntity):
|
||||
"""Defines an base Spook entity for Home Assistant Cloud related entities."""
|
||||
|
||||
@@ -5,12 +5,11 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.cloud import DOMAIN as CLOUD_DOMAIN
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
|
||||
from ...entity import SpookEntityDescription
|
||||
from .entity import HomeAssistantCloudSpookEntity
|
||||
from .entity import CLOUD_DOMAIN, HomeAssistantCloudSpookEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from ....const import DOMAIN
|
||||
@@ -54,11 +54,17 @@ def step_amount(entity: InputNumber, call: ServiceCall) -> float:
|
||||
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}"
|
||||
# A validation error, so the frontend shows this message. It shows a
|
||||
# plain ValueError as "Unknown error".
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="amount_not_a_multiple_of_step",
|
||||
translation_placeholders={
|
||||
"amount": str(amount),
|
||||
"entity_id": entity.entity_id,
|
||||
"step": str(step),
|
||||
},
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
return amount
|
||||
|
||||
|
||||
@@ -13,17 +13,92 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ....const import LOGGER
|
||||
from ....dashboard_extraction import extract_entities_from_dashboard_node
|
||||
from ....dashboard_extraction import (
|
||||
extract_entities_from_dashboard_node,
|
||||
extract_not_entity_ids_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 Iterable
|
||||
|
||||
from homeassistant.components.lovelace.dashboard import (
|
||||
LovelaceStorage,
|
||||
LovelaceYAML,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
@callback
|
||||
def _async_miscased_entity_ids(
|
||||
hass: HomeAssistant, entity_ids: Iterable[str]
|
||||
) -> set[str]:
|
||||
"""Return the entity IDs written with capitals, exactly as written.
|
||||
|
||||
The frontend looks an entity up exactly as the card names it, and every
|
||||
entity Home Assistant has is lower case. So `light.Kitchen` is never
|
||||
found, not even when `light.kitchen` exists, and the card shows it as
|
||||
unavailable. Reported as written, so the repair shows what was typed.
|
||||
|
||||
Only one that is an entity ID once lower cased counts, and it passes the
|
||||
same checks as any other; anything else was never meant as one.
|
||||
"""
|
||||
written_as: dict[str, set[str]] = {}
|
||||
for entity_id in entity_ids:
|
||||
if (lower_cased := entity_id.lower()) != entity_id:
|
||||
written_as.setdefault(lower_cased, set()).add(entity_id)
|
||||
|
||||
# Nothing counts as known here: whether the lower case one exists or
|
||||
# not, the card names something that does not.
|
||||
return {
|
||||
entity_id
|
||||
for lower_cased in async_filter_known_entity_ids(
|
||||
hass, entity_ids=written_as, known_entity_ids=set()
|
||||
)
|
||||
for entity_id in written_as[lower_cased]
|
||||
}
|
||||
|
||||
|
||||
async def _async_describe(
|
||||
hass: HomeAssistant, unknown_entities: set[str], not_entity_ids: set[str]
|
||||
) -> str:
|
||||
"""Return the issue's list of entities, saying which are no entity ID.
|
||||
|
||||
No guess at what one of those was meant to be. `cover.blind.position` may
|
||||
have meant the cover, its position or something else entirely, and a
|
||||
wrong suggestion is worse than none.
|
||||
"""
|
||||
lines = [
|
||||
await async_describe_unknown_entities(hass, sorted(unknown_entities)),
|
||||
*(f"- `{value}` (not an entity ID)" for value in sorted(not_entity_ids)),
|
||||
]
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
def _first_view_with(
|
||||
config: Any, unknown_entities: set[str], not_entity_ids: set[str]
|
||||
) -> int | str:
|
||||
"""Return the path of the first view holding something reported.
|
||||
|
||||
Each view is asked with the walk that reported it: the same malformed
|
||||
value may sit on a custom card on an earlier view, which only the entity
|
||||
walk reads, and that view would be the wrong one to open.
|
||||
"""
|
||||
views = config.get("views") if isinstance(config, dict) else None
|
||||
for view_index, view in enumerate(views if isinstance(views, list) else []):
|
||||
if not isinstance(view, dict):
|
||||
continue
|
||||
|
||||
if unknown_entities & extract_entities_from_dashboard_node(
|
||||
view
|
||||
) or not_entity_ids & extract_not_entity_ids_from_dashboard_node(view):
|
||||
return view.get("path") or view_index
|
||||
|
||||
# A strategy dashboard has no views stored; its views open from the first.
|
||||
return 0
|
||||
|
||||
|
||||
class SpookRepair(AbstractSpookRepair):
|
||||
@@ -64,26 +139,25 @@ class SpookRepair(AbstractSpookRepair):
|
||||
continue
|
||||
|
||||
extracted_entities = self.__async_extract_entities(config)
|
||||
if unknown_entities := async_filter_known_entity_ids(
|
||||
not_entity_ids = extract_not_entity_ids_from_dashboard_node(config)
|
||||
unknown_entities = async_filter_known_entity_ids(
|
||||
self.hass,
|
||||
entity_ids=set(extracted_entities.keys()),
|
||||
known_entity_ids=known_entity_ids,
|
||||
):
|
||||
# Get the view path of the first unknown entity (by view order)
|
||||
first_view_path = next(
|
||||
path
|
||||
for entity_id, path in extracted_entities.items()
|
||||
if entity_id in unknown_entities
|
||||
) | _async_miscased_entity_ids(self.hass, extracted_entities)
|
||||
if unknown_entities or not_entity_ids:
|
||||
first_view_path = _first_view_with(
|
||||
config, unknown_entities, not_entity_ids
|
||||
)
|
||||
title = "Overview"
|
||||
if dashboard.config:
|
||||
title = dashboard.config.get("title", url_path)
|
||||
self.async_create_issue(
|
||||
issue_id=url_path,
|
||||
references=unknown_entities,
|
||||
references=unknown_entities | not_entity_ids,
|
||||
translation_placeholders={
|
||||
"entities": await async_describe_unknown_entities(
|
||||
self.hass, sorted(unknown_entities)
|
||||
"entities": await _async_describe(
|
||||
self.hass, unknown_entities, not_entity_ids
|
||||
),
|
||||
"dashboard": title,
|
||||
"edit": f"/{url_path}/{first_view_path}?edit=1",
|
||||
@@ -95,7 +169,7 @@ class SpookRepair(AbstractSpookRepair):
|
||||
"and created an issue for it; Entities: %s"
|
||||
),
|
||||
title,
|
||||
", ".join(unknown_entities),
|
||||
", ".join(unknown_entities | not_entity_ids),
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -109,4 +183,14 @@ class SpookRepair(AbstractSpookRepair):
|
||||
view_path: int | str = view.get("path") or view_index
|
||||
for entity_id in extract_entities_from_dashboard_node(view):
|
||||
entities.setdefault(entity_id, view_path)
|
||||
|
||||
# A dashboard run by a strategy, like the areas dashboard, has no views
|
||||
# stored at all: only the strategy and its options. The views it makes
|
||||
# are opened from the first one.
|
||||
if isinstance(config, dict) and isinstance(
|
||||
strategy := config.get("strategy"), dict
|
||||
):
|
||||
for entity_id in extract_entities_from_dashboard_node(strategy):
|
||||
entities.setdefault(entity_id, 0)
|
||||
|
||||
return entities
|
||||
|
||||
@@ -1,50 +1 @@
|
||||
"""Spook - Your homie."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from ....const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components.number import NumberEntity
|
||||
|
||||
|
||||
def shown_value_as_float(entity: NumberEntity) -> float:
|
||||
"""Return the value of a number entity, as it shows it, as a float.
|
||||
|
||||
In the units it shows, which are not always the units it works in: a
|
||||
thermostat that keeps Celsius can be shown in Fahrenheit. Stepping from
|
||||
this means stepping in what somebody sees, and the result has to go back
|
||||
through `async_set_shown_value` to land in the units the entity keeps.
|
||||
"""
|
||||
try:
|
||||
return float(entity.value)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="value_not_a_number",
|
||||
translation_placeholders={
|
||||
"value": repr(entity.value),
|
||||
"entity_id": entity.entity_id,
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
async def async_set_shown_value(entity: NumberEntity, value: float) -> None:
|
||||
"""Set a value given in the units the number shows, the way core does.
|
||||
|
||||
Converted to the units the entity works in, and kept within the limits
|
||||
it has there. Handing the shown value over as it is set a thermostat
|
||||
showing 68 °F to 69 °C on a step of one degree.
|
||||
"""
|
||||
try:
|
||||
native_value = entity.convert_to_native_value(value)
|
||||
native_value = min(
|
||||
max(native_value, entity.native_min_value), entity.native_max_value
|
||||
)
|
||||
await entity.async_set_native_value(native_value)
|
||||
except NotImplementedError:
|
||||
await entity.async_set_value(value)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -10,7 +9,7 @@ import voluptuous as vol
|
||||
from homeassistant.components.number import DOMAIN, NumberEntity
|
||||
|
||||
from ....services import AbstractSpookEntityComponentService
|
||||
from . import async_set_shown_value, shown_value_as_float
|
||||
from ..stepping import async_set_shown_value, shown_value_as_float, step_amount
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
@@ -29,22 +28,7 @@ class SpookService(AbstractSpookEntityComponentService[NumberEntity]):
|
||||
call: ServiceCall,
|
||||
) -> None:
|
||||
"""Handle the service call."""
|
||||
step = entity.step or 1
|
||||
amount = call.data.get("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=1e-9)
|
||||
or math.isclose(remainder, step, rel_tol=0, abs_tol=1e-9)
|
||||
):
|
||||
msg = (
|
||||
f"Amount {amount} not valid for {entity.entity_id}, "
|
||||
f"it needs to be a multiple of {step}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
value = shown_value_as_float(entity) - amount
|
||||
value = shown_value_as_float(entity) - step_amount(entity, call)
|
||||
|
||||
if entity.min_value is not None:
|
||||
value = max(value, entity.min_value)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -10,7 +9,7 @@ import voluptuous as vol
|
||||
from homeassistant.components.number import DOMAIN, NumberEntity
|
||||
|
||||
from ....services import AbstractSpookEntityComponentService
|
||||
from . import async_set_shown_value, shown_value_as_float
|
||||
from ..stepping import async_set_shown_value, shown_value_as_float, step_amount
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import ServiceCall
|
||||
@@ -29,22 +28,7 @@ class SpookService(AbstractSpookEntityComponentService[NumberEntity]):
|
||||
call: ServiceCall,
|
||||
) -> None:
|
||||
"""Handle the service call."""
|
||||
step = entity.step or 1
|
||||
amount = call.data.get("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=1e-9)
|
||||
or math.isclose(remainder, step, rel_tol=0, abs_tol=1e-9)
|
||||
):
|
||||
msg = (
|
||||
f"Amount {amount} not valid for {entity.entity_id}, "
|
||||
f"it needs to be a multiple of {step}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
value = shown_value_as_float(entity) + amount
|
||||
value = shown_value_as_float(entity) + step_amount(entity, call)
|
||||
|
||||
if entity.max_value is not None:
|
||||
value = min(value, entity.max_value)
|
||||
|
||||
@@ -10,9 +10,11 @@ import voluptuous as vol
|
||||
from homeassistant.components.person import DOMAIN, Person, PersonStorageCollection
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import DATA_INSTANCES
|
||||
|
||||
from ....const import DOMAIN as SPOOK_DOMAIN
|
||||
from ....errors import entity_not_found
|
||||
from ....helper_collections import async_get_storage_collection
|
||||
from ....services import AbstractSpookAdminService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -35,9 +37,12 @@ class SpookService(AbstractSpookAdminService):
|
||||
|
||||
async def async_handle_service(self, call: ServiceCall) -> None:
|
||||
"""Handle the service call."""
|
||||
collection: PersonStorageCollection
|
||||
entity_component: EntityComponent[Person]
|
||||
_, collection, entity_component = self.hass.data[DOMAIN]
|
||||
collection: PersonStorageCollection = async_get_storage_collection(
|
||||
self.hass, DOMAIN
|
||||
)
|
||||
entity_component: EntityComponent[Person] = self.hass.data[DATA_INSTANCES][
|
||||
DOMAIN
|
||||
]
|
||||
|
||||
if not (entity := entity_component.get_entity(call.data["entity_id"])):
|
||||
raise entity_not_found(call.data["entity_id"])
|
||||
|
||||
@@ -10,9 +10,11 @@ import voluptuous as vol
|
||||
from homeassistant.components.person import DOMAIN, Person, PersonStorageCollection
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_component import DATA_INSTANCES
|
||||
|
||||
from ....const import DOMAIN as SPOOK_DOMAIN
|
||||
from ....errors import entity_not_found
|
||||
from ....helper_collections import async_get_storage_collection
|
||||
from ....services import AbstractSpookAdminService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -35,9 +37,12 @@ class SpookService(AbstractSpookAdminService):
|
||||
|
||||
async def async_handle_service(self, call: ServiceCall) -> None:
|
||||
"""Handle the service call."""
|
||||
collection: PersonStorageCollection
|
||||
entity_component: EntityComponent[Person]
|
||||
_, collection, entity_component = self.hass.data[DOMAIN]
|
||||
collection: PersonStorageCollection = async_get_storage_collection(
|
||||
self.hass, DOMAIN
|
||||
)
|
||||
entity_component: EntityComponent[Person] = self.hass.data[DATA_INSTANCES][
|
||||
DOMAIN
|
||||
]
|
||||
|
||||
if not (entity := entity_component.get_entity(call.data["entity_id"])):
|
||||
raise entity_not_found(call.data["entity_id"])
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import script
|
||||
@@ -9,7 +10,8 @@ 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,
|
||||
async_raw_config_references,
|
||||
core_references,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
@@ -39,16 +41,17 @@ 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.script.referenced_areas)
|
||||
area_ids = core_references(entity, "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)
|
||||
# Templates that look up an area that is not there are read too.
|
||||
references = partial(async_raw_config_references, self.hass, kind="areas")
|
||||
area_ids.update(references(raw_config))
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
area_ids -= only_in_disabled_steps(
|
||||
raw_config, lambda found: extract_targets_from_config(found).area_ids
|
||||
)
|
||||
# Read the same way, or a template there still counts.
|
||||
area_ids -= only_in_disabled_steps(raw_config, references)
|
||||
|
||||
return async_filter_known_area_ids(
|
||||
self.hass,
|
||||
|
||||
@@ -9,7 +9,9 @@ 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 (
|
||||
core_references,
|
||||
extract_targets_from_config,
|
||||
names_given_to_templates,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
@@ -41,20 +43,22 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown device IDs referenced by ``entity``."""
|
||||
device_ids = set(entity.script.referenced_devices)
|
||||
device_ids = core_references(entity, "devices")
|
||||
|
||||
# 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):
|
||||
device_ids.update(extract_targets_from_config(raw_config).device_ids)
|
||||
# Devices referenced via device_entities() in templates.
|
||||
device_ids.update(extract_device_ids_from_config(raw_config))
|
||||
# Devices referenced through the device functions in templates.
|
||||
# The variables and fields it gives its templates hide a call.
|
||||
shadowed = names_given_to_templates(raw_config)
|
||||
device_ids.update(extract_device_ids_from_config(raw_config, shadowed))
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
device_ids -= only_in_disabled_steps(
|
||||
raw_config,
|
||||
lambda found: (
|
||||
extract_targets_from_config(found).device_ids
|
||||
| extract_device_ids_from_config(found)
|
||||
| extract_device_ids_from_config(found, shadowed)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,29 +8,25 @@ from homeassistant.components import script
|
||||
from homeassistant.const import EVENT_COMPONENT_LOADED
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ....action_extraction import async_extract_entities_from_action_config
|
||||
from ....action_extraction import (
|
||||
async_extract_entities_from_action_config,
|
||||
async_unknown_entities_named,
|
||||
)
|
||||
from ....entity_filtering import async_get_all_entity_ids, async_get_all_services
|
||||
from ....reference_extraction import without_disabled_steps
|
||||
from ....reference_extraction import (
|
||||
core_references,
|
||||
names_given_to_templates,
|
||||
without_never_rendered,
|
||||
)
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
from ....template_extraction import (
|
||||
async_extract_entities_from_config,
|
||||
async_filter_known_entity_ids_with_templates,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
def extract_referenced_entities_from_script(entity: script.ScriptEntity) -> set[str]:
|
||||
"""Return entity references from a script entity."""
|
||||
try:
|
||||
return set(entity.script.referenced_entities)
|
||||
except TypeError as err:
|
||||
if str(err) != "unhashable type: 'dict'":
|
||||
raise
|
||||
return set()
|
||||
|
||||
|
||||
async def extract_template_entities_from_script_entity(
|
||||
hass: HomeAssistant,
|
||||
entity: Any,
|
||||
@@ -53,7 +49,14 @@ async def extract_template_entities_from_script_entity(
|
||||
if not (config := getattr(entity, "raw_config", None)):
|
||||
return set()
|
||||
|
||||
return await async_extract_entities_from_config(hass, config, known_services)
|
||||
# A description, a name or a field example is shown, never rendered: an
|
||||
# entity in an example template there is not one the script uses.
|
||||
return await async_extract_entities_from_config(
|
||||
hass,
|
||||
without_never_rendered(config),
|
||||
known_services,
|
||||
names_given_to_templates(config),
|
||||
)
|
||||
|
||||
|
||||
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
@@ -89,12 +92,15 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
)
|
||||
self._known_services = async_get_all_services(self.hass)
|
||||
|
||||
async def _async_named_in(self, config: dict[str, Any]) -> set[str]:
|
||||
async def _async_named_in(
|
||||
self, config: dict[str, Any], shadowed: frozenset[str]
|
||||
) -> set[str]:
|
||||
"""Return the entities a configuration names, the way this repair reads it.
|
||||
|
||||
The same reading the report is built from, the steps and templates
|
||||
alike, so comparing it with and without the disabled steps leaves out
|
||||
exactly what only those name.
|
||||
exactly what only those name. ``shadowed`` comes from the whole
|
||||
configuration, as `config` can be one without its disabled steps.
|
||||
"""
|
||||
steps = config.get("sequence") or []
|
||||
named = await async_extract_entities_from_action_config(
|
||||
@@ -102,16 +108,20 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
# A single step can be written without a list.
|
||||
[steps] if isinstance(steps, dict) else steps,
|
||||
known_services=self._known_services,
|
||||
shadowed=shadowed,
|
||||
)
|
||||
named |= await async_extract_entities_from_config(
|
||||
self.hass, config, self._known_services
|
||||
self.hass,
|
||||
without_never_rendered(config),
|
||||
self._known_services,
|
||||
shadowed,
|
||||
)
|
||||
return named
|
||||
|
||||
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
|
||||
"""Return unknown entity IDs referenced by ``entity`` (incl. templates)."""
|
||||
# Get all referenced entities from the script
|
||||
all_entities = extract_referenced_entities_from_script(entity)
|
||||
all_entities = core_references(entity, "entities")
|
||||
|
||||
# Home Assistant's own list leaves out entities handed over as action
|
||||
# data, like `entity: light.kitchen` in a call to another script. The
|
||||
@@ -123,6 +133,7 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
self.hass,
|
||||
raw_config.get("sequence") or [],
|
||||
known_services=self._known_services,
|
||||
shadowed=names_given_to_templates(raw_config),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -133,18 +144,11 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
)
|
||||
)
|
||||
|
||||
# Home Assistant's own list includes disabled steps too. A step parked
|
||||
# that way does nothing, so what only it names is left out: whatever
|
||||
# this repair finds in the configuration, and no longer finds once
|
||||
# those are pruned.
|
||||
if isinstance(raw_config, dict):
|
||||
named = await self._async_named_in(raw_config)
|
||||
still_named = await self._async_named_in(without_disabled_steps(raw_config))
|
||||
all_entities -= named - still_named
|
||||
|
||||
return await async_filter_known_entity_ids_with_templates(
|
||||
return await async_unknown_entities_named(
|
||||
self.hass,
|
||||
entity_ids=all_entities,
|
||||
all_entities,
|
||||
raw_config,
|
||||
named_in=self._async_named_in,
|
||||
known_entity_ids=self._known_entity_ids,
|
||||
known_services=self._known_services,
|
||||
)
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import script
|
||||
from homeassistant.helpers import floor_registry as fr
|
||||
from homeassistant.helpers import area_registry as ar, 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,
|
||||
async_raw_config_references,
|
||||
core_references,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
@@ -23,7 +25,12 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
|
||||
|
||||
domain = script.DOMAIN
|
||||
repair = "script_unknown_floor_references"
|
||||
inspect_events = {fr.EVENT_FLOOR_REGISTRY_UPDATED}
|
||||
# Templates look floors up through areas as well, so a renamed area
|
||||
# can change what they find.
|
||||
inspect_events = {
|
||||
ar.EVENT_AREA_REGISTRY_UPDATED,
|
||||
fr.EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
}
|
||||
inspect_on_reload = True
|
||||
|
||||
unavailable_entity_class = script.UnavailableScriptEntity
|
||||
@@ -39,16 +46,17 @@ 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.script.referenced_floors)
|
||||
floor_ids = core_references(entity, "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)
|
||||
# Templates that look up a floor that is not there are read too.
|
||||
references = partial(async_raw_config_references, self.hass, kind="floors")
|
||||
floor_ids.update(references(raw_config))
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
floor_ids -= only_in_disabled_steps(
|
||||
raw_config, lambda found: extract_targets_from_config(found).floor_ids
|
||||
)
|
||||
# Read the same way, or a template there still counts.
|
||||
floor_ids -= only_in_disabled_steps(raw_config, references)
|
||||
|
||||
return async_filter_known_floor_ids(
|
||||
self.hass,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components import script
|
||||
@@ -9,7 +10,8 @@ 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,
|
||||
async_raw_config_references,
|
||||
core_references,
|
||||
only_in_disabled_steps,
|
||||
)
|
||||
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
|
||||
@@ -39,16 +41,17 @@ 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.script.referenced_labels)
|
||||
label_ids = core_references(entity, "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)
|
||||
# Templates that look up a label that is not there are read too.
|
||||
references = partial(async_raw_config_references, self.hass, kind="labels")
|
||||
label_ids.update(references(raw_config))
|
||||
# A disabled step does nothing, so what only it names is left out.
|
||||
label_ids -= only_in_disabled_steps(
|
||||
raw_config, lambda found: extract_targets_from_config(found).label_ids
|
||||
)
|
||||
# Read the same way, or a template there still counts.
|
||||
label_ids -= only_in_disabled_steps(raw_config, references)
|
||||
|
||||
return async_filter_known_label_ids(
|
||||
self.hass,
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from ....const import DOMAIN as SPOOK_DOMAIN
|
||||
from ....helper_collections import async_get_storage_collection
|
||||
from ....services import AbstractSpookEntityComponentService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -63,13 +64,8 @@ class SpookService(AbstractSpookEntityComponentService[Timer]):
|
||||
}
|
||||
)
|
||||
|
||||
collection: TimerStorageCollection
|
||||
if DOMAIN in entity.hass.data:
|
||||
collection = entity.hass.data[DOMAIN]
|
||||
else:
|
||||
# Major hack borrowed from ../../zone/services/create.py:27 👻
|
||||
collection = entity.hass.data["websocket_api"]["timer/list"][
|
||||
0
|
||||
].__self__.storage_collection
|
||||
collection: TimerStorageCollection = async_get_storage_collection(
|
||||
entity.hass, DOMAIN
|
||||
)
|
||||
|
||||
await collection.async_update_item(item_id, updates)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.zone import CREATE_FIELDS, DOMAIN, ZoneStorageCollection
|
||||
|
||||
from ....helper_collections import async_get_storage_collection
|
||||
from ....services import AbstractSpookAdminService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -21,15 +22,8 @@ class SpookService(AbstractSpookAdminService):
|
||||
|
||||
async def async_handle_service(self, call: ServiceCall) -> None:
|
||||
"""Handle the service call."""
|
||||
collection: ZoneStorageCollection
|
||||
if DOMAIN in self.hass.data:
|
||||
collection = self.hass.data[DOMAIN]
|
||||
else:
|
||||
# Home zone is set in YAML, as a result Home Assistant doesn't
|
||||
# set the storage collection into hass data.
|
||||
# Major hack to get around this. 👻
|
||||
collection = self.hass.data["websocket_api"]["zone/list"][
|
||||
0
|
||||
].__self__.storage_collection
|
||||
collection: ZoneStorageCollection = async_get_storage_collection(
|
||||
self.hass, DOMAIN
|
||||
)
|
||||
|
||||
await collection.async_create_item(call.data.copy())
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_component import DATA_INSTANCES, EntityCompone
|
||||
|
||||
from ....const import DOMAIN as SPOOK_DOMAIN
|
||||
from ....errors import entity_not_found
|
||||
from ....helper_collections import async_get_storage_collection
|
||||
from ....services import AbstractSpookAdminService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -32,16 +33,9 @@ class SpookService(AbstractSpookAdminService):
|
||||
"""Handle the service call."""
|
||||
entity_component: EntityComponent[Zone] = self.hass.data[DATA_INSTANCES][DOMAIN]
|
||||
|
||||
collection: ZoneStorageCollection
|
||||
if DOMAIN in self.hass.data:
|
||||
collection = self.hass.data[DOMAIN]
|
||||
else:
|
||||
# Home zone is set in YAML, as a result Home Assistant doesn't
|
||||
# set the storage collection into hass data.
|
||||
# Major hack to get around this. 👻
|
||||
collection = self.hass.data["websocket_api"]["zone/list"][
|
||||
0
|
||||
].__self__.storage_collection
|
||||
collection: ZoneStorageCollection = async_get_storage_collection(
|
||||
self.hass, DOMAIN
|
||||
)
|
||||
|
||||
# Everything is looked up before anything is written. A bad one late
|
||||
# in the list should not leave the first ones changed behind an error.
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.helpers.entity_component import DATA_INSTANCES, EntityCompone
|
||||
|
||||
from ....const import DOMAIN as SPOOK_DOMAIN
|
||||
from ....errors import entity_not_found
|
||||
from ....helper_collections import async_get_storage_collection
|
||||
from ....services import AbstractSpookAdminService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -55,16 +56,9 @@ class SpookService(AbstractSpookAdminService):
|
||||
"""Handle the service call."""
|
||||
entity_component: EntityComponent[Zone] = self.hass.data[DATA_INSTANCES][DOMAIN]
|
||||
|
||||
collection: ZoneStorageCollection
|
||||
if DOMAIN in self.hass.data:
|
||||
collection = self.hass.data[DOMAIN]
|
||||
else:
|
||||
# Home zone is set in YAML, as a result Home Assistant doesn't
|
||||
# set the storage collection into hass data.
|
||||
# Major hack to get around this. 👻
|
||||
collection = self.hass.data["websocket_api"]["zone/list"][
|
||||
0
|
||||
].__self__.storage_collection
|
||||
collection: ZoneStorageCollection = async_get_storage_collection(
|
||||
self.hass, DOMAIN
|
||||
)
|
||||
|
||||
if not (entity := entity_component.get_entity(call.data["entity_id"])):
|
||||
raise entity_not_found(call.data["entity_id"])
|
||||
|
||||
Reference in New Issue
Block a user