291 files

This commit is contained in:
Home Assistant Version Control
2026-10-14 18:10:18 +00:00
parent c4e0178d8a
commit 280501d2e8
291 changed files with 32540 additions and 3280 deletions
@@ -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)
),
)
@@ -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"])