361 files

This commit is contained in:
Home Assistant Version Control
2026-10-06 17:11:43 +00:00
parent d2c5af43e3
commit bf886c9347
362 changed files with 22060 additions and 1285 deletions
@@ -1 +1,70 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components import automation
from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON
from homeassistant.core import callback
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
if TYPE_CHECKING:
from collections.abc import Mapping
from homeassistant.core import Event
class AbstractSpookAutomationReferencesRepair(
AbstractSpookEntityComponentUnknownReferencesRepair
):
"""Base for the repairs that look for unknown references in automations.
An automation somebody turned off is left alone. It does nothing while it
is off, so nothing in it can go wrong yet, and it was often turned off
exactly because something in it is broken: being told about that again
and again is noise. The moment it is turned back on is the moment it
matters, so that is when it is looked at again. #1725.
"""
def _should_inspect_entity(self, entity: Any) -> bool:
"""Look only at automations that are on.
One that failed to load is not off, it is broken, and some of these
repairs exist to say exactly why. That is not somebody's choice to
leave it alone, so it is still looked at.
"""
if not isinstance(entity, automation.AutomationEntity):
return True
return bool(entity.is_on)
async def async_activate(self) -> None:
"""Look again whenever an automation is turned on or off.
Turned on, it is reported straight away rather than at the next look.
Turned off, what was reported for it is cleared then too.
"""
await super().async_activate()
@callback
def _turned_on_or_off(event_data: Mapping[str, Any]) -> bool:
"""Return whether an automation went from off to on, or back."""
if not event_data["entity_id"].startswith(f"{automation.DOMAIN}."):
return False
if (old := event_data["old_state"]) is None or (
new := event_data["new_state"]
) is None:
return False
# Every run updates an automation's attributes, and those are not
# a reason to look; only the switch between on and off is.
return {old.state, new.state} == {STATE_ON, STATE_OFF}
async def _look_again(_: Event) -> None:
await self.inspect_debouncer.async_call()
self._event_subs.add(
self.hass.bus.async_listen(
EVENT_STATE_CHANGED, _look_again, event_filter=_turned_on_or_off
)
)
@@ -9,13 +9,13 @@ from homeassistant.helpers import area_registry as ar
from ....entity_filtering import async_filter_known_area_ids, async_get_all_area_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from . import AbstractSpookAutomationReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced areas in automations."""
domain = automation.DOMAIN
@@ -9,8 +9,8 @@ from homeassistant.helpers import device_registry as dr
from ....entity_filtering import async_filter_known_device_ids, async_get_all_device_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from ....template_extraction import extract_device_ids_from_config
from . import AbstractSpookAutomationReferencesRepair
def extract_event_data_device_ids_from_trigger_config(
@@ -46,7 +46,7 @@ def extract_event_data_device_ids_from_trigger_config(
return device_ids
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced devices in automations."""
domain = automation.DOMAIN
@@ -5,8 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components import automation
from homeassistant.const import EVENT_COMPONENT_LOADED, EVENT_STATE_CHANGED
from homeassistant.core import Event, callback
from homeassistant.const import EVENT_COMPONENT_LOADED
from homeassistant.helpers import entity_registry as er
from ....action_extraction import (
@@ -14,16 +13,14 @@ from ....action_extraction import (
async_extract_entities_from_value,
)
from ....entity_filtering import async_get_all_entity_ids, async_get_all_services
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from ....template_extraction import (
KNOWN_DOMAINS,
async_extract_entities_from_config,
async_filter_known_entity_ids_with_templates,
)
from . import AbstractSpookAutomationReferencesRepair
if TYPE_CHECKING:
from collections.abc import Mapping
from homeassistant.core import HomeAssistant
@@ -260,7 +257,7 @@ async def extract_entities_from_condition_config(
return entities
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced entity in automations."""
domain = automation.DOMAIN
@@ -271,6 +268,7 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
}
inspect_config_entry_changed = True
inspect_on_reload = True
inspect_on_entity_added_or_removed = True
unavailable_entity_class = automation.UnavailableAutomationEntity
entity_label = "automation"
@@ -281,31 +279,6 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
_known_entity_ids: set[str]
_known_services: set[str]
async def async_activate(self) -> None:
"""Activate the repair."""
await super().async_activate()
@callback
def _state_entity_changed(event_data: Mapping[str, Any]) -> bool:
"""Return if a state entity was added or removed."""
return (
event_data.get("old_state") is None
or event_data.get("new_state") is None
)
@callback
def _async_call_inspect_debouncer(_: Event) -> None:
"""Trigger an inspection when a state entity is added or removed."""
self.inspect_debouncer.async_schedule_call()
self._event_subs.add(
self.hass.bus.async_listen(
EVENT_STATE_CHANGED,
_async_call_inspect_debouncer,
event_filter=_state_entity_changed,
),
)
async def _async_setup_inspection(self) -> None:
"""Cache what every automation in this cycle needs looked up.
@@ -318,10 +291,6 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
)
self._known_services = async_get_all_services(self.hass)
def _should_inspect_entity(self, entity: Any) -> bool:
"""Skip disabled automations."""
return entity.enabled
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)
@@ -9,13 +9,13 @@ from homeassistant.helpers import floor_registry as fr
from ....entity_filtering import async_filter_known_floor_ids, async_get_all_floor_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from . import AbstractSpookAutomationReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced floors in automations."""
domain = automation.DOMAIN
@@ -9,13 +9,13 @@ from homeassistant.helpers import label_registry as lr
from ....entity_filtering import async_filter_known_label_ids, async_get_all_label_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from . import AbstractSpookAutomationReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced labels in automations."""
domain = automation.DOMAIN
@@ -16,13 +16,13 @@ from ....entity_filtering import (
async_find_services_in_sequence,
async_get_all_services,
)
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from . import AbstractSpookAutomationReferencesRepair
if TYPE_CHECKING:
from typing import Any
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
class SpookRepair(AbstractSpookAutomationReferencesRepair):
"""Spook repair tries to find unknown referenced services in automations."""
domain = automation.DOMAIN
@@ -47,10 +47,6 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
"""Cache known services for this inspection cycle."""
self._known_services = async_get_all_services(self.hass)
def _should_inspect_entity(self, entity: Any) -> bool:
"""Skip disabled automations."""
return entity.enabled
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return unknown services called by ``entity``."""
return async_filter_known_services(
@@ -39,9 +39,12 @@ class SpookRepair(AbstractSpookRepair):
if not (platforms := self.hass.data[DATA_ENTITY_PLATFORM].get(self.domain)):
return # Nothing to do.
for platform in platforms:
# Taken as a snapshot: describing what is missing can hand the event
# loop a turn, and a change to the live collection during it ends the
# inspection in a `RuntimeError`. #1558.
for platform in list(platforms):
# We don't want to check the old style group platform
for entity in platform.entities.values():
for entity in list(platform.entities.values()):
self.possible_issue_ids.add(entity.entity_id)
members = []
if platform.domain == group.DOMAIN:
@@ -56,7 +59,7 @@ class SpookRepair(AbstractSpookRepair):
if unknown_entities := async_filter_known_entity_ids(
self.hass, entity_ids=members, known_entity_ids=known_entity_ids
):
described = async_describe_unknown_entities(
described = await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
)
self.async_create_issue(
@@ -65,6 +68,10 @@ class SpookRepair(AbstractSpookRepair):
is_fixable=True,
data={
"group_entity_id": entity.entity_id,
# What the fix checks again before dropping any.
"group_unknown_entity_ids": ",".join(
sorted(unknown_entities)
),
"group": entity.name,
"entities": described,
},
@@ -47,4 +47,11 @@ class SpookService(AbstractSpookAdminService, ReplaceExistingService):
msg,
)
self.hass.async_run_hass_job(self.overriden_service.job, call)
# Waited on, the way calling Home Assistant's own restart is. It
# checks the configuration first and refuses when that is broken, and
# without waiting that refusal went nowhere: the automation carried
# on as if Home Assistant was on its way back up.
if (
running := self.hass.async_run_hass_job(self.overriden_service.job, call)
) is not None:
await running
@@ -8,9 +8,8 @@ from homeassistant.components.lovelace import DOMAIN
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
EVENT_LOVELACE_UPDATED,
EVENT_STATE_CHANGED,
)
from homeassistant.core import Event, callback
from homeassistant.core import callback
from homeassistant.helpers import entity_registry as er
from ....const import LOGGER
@@ -21,8 +20,6 @@ from ....repairs import AbstractSpookRepair
from ..dashboards import async_dashboard_configs
if TYPE_CHECKING:
from collections.abc import Mapping
from homeassistant.components.lovelace.dashboard import (
LovelaceStorage,
LovelaceYAML,
@@ -41,36 +38,16 @@ class SpookRepair(AbstractSpookRepair):
}
inspect_config_entry_changed = True
inspect_on_reload = True
inspect_on_entity_added_or_removed = True
automatically_clean_up_issues = True
_dashboards: dict[str, LovelaceStorage | LovelaceYAML]
_dashboards: dict[str | None, LovelaceStorage | LovelaceYAML]
async def async_activate(self) -> None:
"""Handle the activating a repair."""
self._dashboards = self.hass.data["lovelace"].dashboards
await super().async_activate()
@callback
def _state_entity_changed(event_data: Mapping[str, Any]) -> bool:
"""Return if a state entity was added or removed."""
return (
event_data.get("old_state") is None
or event_data.get("new_state") is None
)
@callback
def _async_call_inspect_debouncer(_: Event) -> None:
"""Trigger an inspection when a state entity is added or removed."""
self.inspect_debouncer.async_schedule_call()
self._event_subs.add(
self.hass.bus.async_listen(
EVENT_STATE_CHANGED,
_async_call_inspect_debouncer,
event_filter=_state_entity_changed,
),
)
async def async_inspect(self) -> None:
"""Trigger a inspection."""
LOGGER.debug("Spook is inspecting: %s", self.repair)
@@ -105,7 +82,7 @@ class SpookRepair(AbstractSpookRepair):
issue_id=url_path,
references=unknown_entities,
translation_placeholders={
"entities": async_describe_unknown_entities(
"entities": await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
),
"dashboard": title,
@@ -10,10 +10,33 @@ if TYPE_CHECKING:
from homeassistant.components.number import NumberEntity
def native_value_as_float(entity: NumberEntity) -> float:
"""Return the native value of a number entity as a float."""
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:
msg = f"Native value {entity.value!r} for {entity.entity_id} is not a number"
msg = f"Value {entity.value!r} for {entity.entity_id} is not a number"
raise HomeAssistantError(msg) 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)
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.number import DOMAIN, NumberEntity
from ....services import AbstractSpookEntityComponentService
from . import native_value_as_float
from . import async_set_shown_value, shown_value_as_float
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@@ -44,9 +44,9 @@ class SpookService(AbstractSpookEntityComponentService[NumberEntity]):
)
raise ValueError(msg)
value = native_value_as_float(entity) - amount
value = shown_value_as_float(entity) - amount
if entity.min_value is not None:
value = max(value, entity.min_value)
await entity.async_set_native_value(value)
await async_set_shown_value(entity, value)
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.number import DOMAIN, NumberEntity
from ....services import AbstractSpookEntityComponentService
from . import native_value_as_float
from . import async_set_shown_value, shown_value_as_float
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@@ -44,9 +44,9 @@ class SpookService(AbstractSpookEntityComponentService[NumberEntity]):
)
raise ValueError(msg)
value = native_value_as_float(entity) + amount
value = shown_value_as_float(entity) + amount
if entity.max_value is not None:
value = min(value, entity.max_value)
await entity.async_set_native_value(value)
await async_set_shown_value(entity, value)
@@ -54,7 +54,7 @@ class SpookRepair(AbstractSpookRepair):
references=unknown_entities,
translation_placeholders={
"name": coordinator.name,
"zones": async_describe_unknown_entities(
"zones": await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
),
},
@@ -54,7 +54,7 @@ class SpookRepair(AbstractSpookRepair):
references=unknown_entities,
translation_placeholders={
"name": coordinator.name,
"entities": async_describe_unknown_entities(
"entities": await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
),
},
@@ -43,7 +43,10 @@ class SpookRepair(AbstractSpookRepair):
known_entity_ids = async_get_all_entity_ids(self.hass)
for entity in scenes:
# Taken as a snapshot: describing what is missing can hand the event
# loop a turn, and a change to the live collection during it ends the
# inspection in a `RuntimeError`. #1558.
for entity in list(scenes):
self.possible_issue_ids.add(entity.entity_id)
if unknown_entities := async_filter_known_entity_ids(
self.hass,
@@ -62,7 +65,7 @@ class SpookRepair(AbstractSpookRepair):
issue_id=entity.entity_id,
references=unknown_entities,
translation_placeholders={
"entities": async_describe_unknown_entities(
"entities": await async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
),
"scene": entity.name,
@@ -28,6 +28,10 @@ class SpookService(AbstractSpookEntityComponentService[Timer]):
domain = DOMAIN
service = "set_duration"
# This changes the timer as it is stored, which editing it in the UI only
# lets an admin do. Starting or pausing it is somebody using it, this is
# somebody changing it.
admin_only = True
schema = {
vol.Required(CONF_DURATION): cv.time_period,
}