361 files
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user