429 files

This commit is contained in:
Home Assistant Version Control
2026-08-30 02:43:31 +00:00
parent 472c19bceb
commit 963cce81a7
566 changed files with 50311 additions and 47616 deletions
+35 -10
View File
@@ -16,17 +16,18 @@ from homeassistant.core import (
)
from homeassistant.helpers import issue_registry as ir
from .automation_runs import async_setup_automation_runs
from .const import DOMAIN, LOGGER, PLATFORMS
from .entity_filtering import async_setup_all_entity_ids_cache_invalidation
from .integration_linking import link_sub_integrations, unlink_sub_integrations
from .listeners import async_listen_once_tracked
from .repairs import SpookRepairManager
from .run_history import async_setup_run_history
from .services import SpookServiceManager
from .setup_helpers import async_forward_setup_entry
from .timed_states import async_setup_timed_states
if TYPE_CHECKING:
from collections.abc import Callable
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant
@@ -79,6 +80,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Forward async_setup_entry to ectoplasms
await async_forward_setup_entry(hass, entry)
# Before the services, because the actions that hold an automation in a
# state reach for this the moment they are called, and registering them
# first leaves a window where calling one finds nothing there.
#
# This also picks up automations held before the last restart. An
# automation keeps whatever state it had, so without this a snooze that
# spanned a restart would be a disable nobody remembers making.
entry.async_on_unload(await async_setup_timed_states(hass))
# Set up services
services = SpookServiceManager(hass)
await services.async_setup()
@@ -87,18 +97,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Who you gonna call? SpookRepairManager!
repairs = SpookRepairManager(hass)
_ghost_busters_unsub: Callable[[], None] | None = None
async def _ghost_busters(_: Event) -> None:
async def _ghost_busters(_: Event | None = None) -> None:
"""Send them in, time for some ghost chasing."""
await repairs.async_setup()
entry.async_on_unload(repairs.async_on_unload)
# Wait until Home Assistant is started, before doing repairs
_ghost_busters_unsub = async_listen_once_tracked(
hass, EVENT_HOMEASSISTANT_STARTED, _ghost_busters
)
entry.async_on_unload(_ghost_busters_unsub)
if hass.state == CoreState.running:
# Home Assistant is already up, so the started event has been and gone.
# This is the reload path, and setting up for the first time on a
# running instance. Waiting for an event that cannot fire again would
# leave every repair check dead until a core restart, silently.
await _ghost_busters()
else:
# Otherwise wait for a settled instance, so repairs do not inspect a
# half-loaded Home Assistant and report things that are still coming.
entry.async_on_unload(
async_listen_once_tracked(hass, EVENT_HOMEASSISTANT_STARTED, _ghost_busters)
)
# Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -106,6 +121,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Set up the all entity ids cache invalidation
entry.async_on_unload(async_setup_all_entity_ids_cache_invalidation(hass))
# Start noting which automation runs under which context. It has to begin
# here rather than when a condition first asks: a condition inside an
# action sequence is only built once that sequence runs, by which point
# the automation it wants to know about has already been and gone.
entry.async_on_unload(async_setup_automation_runs(hass))
# And when they ran, for the conditions that count runs rather than
# contexts. Same reason it cannot wait to be asked.
entry.async_on_unload(async_setup_run_history(hass))
# Yay, we didn't got spooked!
return True
+72
View File
@@ -0,0 +1,72 @@
triggered_by_user:
fields:
person:
required: false
example: person.frenck
selector:
entity:
domain: person
multiple: true
not_triggered_by_user: {}
chance:
fields:
percentage:
required: true
example: 20
selector:
number:
min: 0
max: 100
step: 1
unit_of_measurement: "%"
mode: slider
cooldown:
fields:
duration:
required: true
example: "00:05:00"
selector:
duration:
triggered_by_automation:
fields:
automation:
required: false
example: automation.goodnight
selector:
entity:
domain: automation
multiple: true
repair_issue_present:
fields:
domain:
required: false
example: hue
selector:
text:
multiple: true
severity:
required: false
selector:
select:
multiple: true
translation_key: repair_issue_severity
options:
- critical
- error
- warning
quota:
fields:
limit:
required: true
example: 5
selector:
number:
min: 1
max: 64
step: 1
mode: box
period:
required: true
example: "24:00:00"
selector:
duration:
@@ -8,6 +8,7 @@ from homeassistant.components import automation
from homeassistant.helpers import area_registry as ar
from ....entity_filtering import async_filter_known_area_ids, async_get_all_area_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
if TYPE_CHECKING:
@@ -38,8 +39,15 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return unknown area IDs referenced by ``entity``."""
area_ids = set(entity.referenced_areas)
# Also walk the raw configuration; the built-in extraction misses
# references nested in some step types, like repeat sequences.
if raw_config := getattr(entity, "raw_config", None):
area_ids.update(extract_targets_from_config(raw_config).area_ids)
return async_filter_known_area_ids(
self.hass,
area_ids=entity.referenced_areas,
area_ids=area_ids,
known_area_ids=self._known_area_ids,
)
@@ -8,7 +8,9 @@ from homeassistant.components import automation
from homeassistant.helpers import device_registry as dr
from ....entity_filtering import async_filter_known_device_ids, async_get_all_device_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from ....template_extraction import extract_device_ids_from_config
def extract_event_data_device_ids_from_trigger_config(
@@ -72,6 +74,11 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
device_ids = set(entity.referenced_devices)
if hasattr(entity, "raw_config") and entity.raw_config:
# Also walk the raw configuration; the built-in extraction misses
# references nested in some step types, like repeat sequences.
# The walker never collects from event trigger payloads, so the
# subtraction below only affects built-in extraction results.
device_ids.update(extract_targets_from_config(entity.raw_config).device_ids)
device_ids.difference_update(
extract_event_data_device_ids_from_trigger_config(
entity.raw_config.get("trigger")
@@ -82,6 +89,8 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
entity.raw_config.get("triggers")
)
)
# Devices referenced via device_entities() in templates.
device_ids.update(extract_device_ids_from_config(entity.raw_config))
return async_filter_known_device_ids(
self.hass,
@@ -2,7 +2,6 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from homeassistant.components import automation
@@ -10,16 +9,17 @@ from homeassistant.const import EVENT_COMPONENT_LOADED, EVENT_STATE_CHANGED
from homeassistant.core import Event, callback
from homeassistant.helpers import entity_registry as er
from ....const import LOGGER
from ....entity_filtering import (
ENTITY_ID_PATTERN,
async_extract_entities_from_config,
async_extract_entities_from_template_string,
async_filter_known_entity_ids_with_templates,
async_get_all_entity_ids,
is_template_string,
from ....action_extraction import (
async_extract_entities_from_action_config,
async_extract_entities_from_value,
)
from ....entity_filtering import async_get_all_entity_ids, async_get_all_services
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
from ....template_extraction import (
KNOWN_DOMAINS,
async_extract_entities_from_config,
async_filter_known_entity_ids_with_templates,
)
if TYPE_CHECKING:
from collections.abc import Mapping
@@ -28,7 +28,9 @@ if TYPE_CHECKING:
async def extract_template_entities_from_automation_entity(
hass: HomeAssistant, entity: Any
hass: HomeAssistant,
entity: Any,
known_services: set[str] | None = None,
) -> set[str]:
"""Extract entities from automation configuration using Template analysis.
@@ -43,44 +45,88 @@ async def extract_template_entities_from_automation_entity(
else:
return set()
return await async_extract_entities_from_config(hass, config)
return await async_extract_entities_from_config(hass, config, known_services)
async def extract_entities_from_automation_config(
hass: HomeAssistant, config: dict[str, Any]
hass: HomeAssistant,
config: dict[str, Any],
known_services: set[str] | None = None,
) -> set[str]:
"""Extract entity IDs from automation configuration."""
"""Extract entity IDs from automation configuration.
``known_services`` is built once per inspection and handed down, because
building it flattens every service Home Assistant has and the walk below
passes a lot of template strings. See
`action_extraction.async_extract_entities_from_action_config`.
"""
entities = set()
if not isinstance(config, dict):
return entities
if known_services is None:
known_services = async_get_all_services(hass)
# Extract entities from trigger config
for key in ("trigger", "triggers"):
if key in config:
entities.update(
await extract_entities_from_trigger_config(hass, config[key])
await extract_entities_from_trigger_config(
hass, config[key], known_services
)
)
# Extract entities from condition config
for key in ("condition", "conditions"):
if key in config:
entities.update(
await extract_entities_from_condition_config(hass, config[key])
await extract_entities_from_condition_config(
hass, config[key], known_services
)
)
# Extract entities from action config
for key in ("action", "actions"):
if key in config:
entities.update(
await extract_entities_from_action_config(hass, config[key])
await async_extract_entities_from_action_config(
hass, config[key], known_services=known_services
)
)
return entities
async def _entities_from_reference_fields(
hass: HomeAssistant,
config: dict[str, Any],
known_services: set[str],
) -> set[str]:
"""Extract entities from the config keys that name a reference.
``zone`` is in here because a zone trigger and a zone condition both name
one, and it is read exactly like the others.
"""
entities = set()
for key in ("entity_id", "device_id", "zone"):
if key in config:
entities.update(
await async_extract_entities_from_value(
hass, config[key], known_services=known_services
)
)
return entities
# Where an event trigger keeps what it matches on, rather than what it needs.
_EVENT_PAYLOAD_KEYS = frozenset({"event_data", "event_data_template"})
async def extract_entities_from_trigger_config(
hass: HomeAssistant, config: dict[str, Any] | list
hass: HomeAssistant,
config: dict[str, Any] | list,
known_services: set[str] | None = None,
) -> set[str]:
"""Extract entity IDs from trigger configuration."""
entities = set()
@@ -88,31 +134,66 @@ async def extract_entities_from_trigger_config(
if not config:
return entities
if known_services is None:
known_services = async_get_all_services(hass)
if isinstance(config, list):
for item in config:
entities.update(await extract_entities_from_trigger_config(hass, item))
entities.update(
await extract_entities_from_trigger_config(hass, item, known_services)
)
return entities
if not isinstance(config, dict):
return entities
# Entity ID fields in triggers
for key in ("entity_id", "device_id"):
if key in config:
entities.update(await extract_entities_from_value(hass, config[key]))
entities.update(await _entities_from_reference_fields(hass, config, known_services))
# Zone trigger has zone field
if "zone" in config:
entities.update(await extract_entities_from_value(hass, config["zone"]))
payload_keys = _payload_keys_to_leave_alone(config)
# Extract from nested configs
for value in config.values():
for key, value in config.items():
if key in payload_keys:
continue
if isinstance(value, (dict, list)):
entities.update(await extract_entities_from_trigger_config(hass, value))
entities.update(
await extract_entities_from_trigger_config(hass, value, known_services)
)
return entities
def _payload_keys_to_leave_alone(config: dict[str, Any]) -> frozenset[str]:
"""Return the event payload keys that hold data rather than references.
An `entity_id` inside `event_data` is a reference when the event comes
from an integration that means it that way, `timer.finished` being the
usual one. On somebody's own event it is whatever the sender put there,
and reporting that as a missing entity is a repair about an automation
that works perfectly well.
Told apart by the event type: one named after a domain comes from that
integration, anything else is somebody's own.
"""
event_types = config.get("event_type")
if event_types is None:
return frozenset()
if isinstance(event_types, str):
event_types = [event_types]
for event_type in event_types:
if not isinstance(event_type, str):
continue
domain, dot, _ = event_type.partition(".")
if dot and domain in KNOWN_DOMAINS:
return frozenset()
return _EVENT_PAYLOAD_KEYS
def extract_event_types_from_trigger_config(config: dict[str, Any] | list) -> set[str]:
"""Extract event types from trigger configuration."""
event_types = set()
@@ -142,7 +223,9 @@ def extract_event_types_from_trigger_config(config: dict[str, Any] | list) -> se
async def extract_entities_from_condition_config(
hass: HomeAssistant, config: dict[str, Any] | list
hass: HomeAssistant,
config: dict[str, Any] | list,
known_services: set[str] | None = None,
) -> set[str]:
"""Extract entity IDs from condition configuration."""
entities = set()
@@ -150,161 +233,29 @@ async def extract_entities_from_condition_config(
if not config:
return entities
if known_services is None:
known_services = async_get_all_services(hass)
if isinstance(config, list):
for item in config:
entities.update(await extract_entities_from_condition_config(hass, item))
entities.update(
await extract_entities_from_condition_config(hass, item, known_services)
)
return entities
if not isinstance(config, dict):
return entities
# Entity ID fields in conditions
for key in ("entity_id", "device_id", "zone"):
if key in config:
entities.update(await extract_entities_from_value(hass, config[key]))
entities.update(await _entities_from_reference_fields(hass, config, known_services))
# Extract from nested configs
for value in config.values():
if isinstance(value, (dict, list)):
entities.update(await extract_entities_from_condition_config(hass, value))
return entities
async def extract_entities_from_action_config(
hass: HomeAssistant, config: dict[str, Any] | list
) -> set[str]:
"""Extract entity IDs from action configuration."""
entities = set()
if not config:
return entities
if isinstance(config, list):
for item in config:
entities.update(await extract_entities_from_action_config(hass, item))
return entities
if not isinstance(config, dict):
return entities
# Extract entity IDs from direct fields
entities.update(await _extract_entities_from_action_fields(hass, config))
# Extract entities from target configuration
entities.update(await _extract_entities_from_target(hass, config))
# Extract entities from service data
entities.update(await _extract_entities_from_service_data(hass, config))
# Extract from nested configs (like if/then/else, repeat, etc.)
entities.update(await _extract_entities_from_nested_configs(hass, config))
return entities
async def _extract_entities_from_action_fields(
hass: HomeAssistant, config: dict[str, Any]
) -> set[str]:
"""Extract entities from direct action fields."""
entities = set()
for key in ("entity_id", "device_id"):
if key in config:
entities.update(await extract_entities_from_value(hass, config[key]))
return entities
async def _extract_entities_from_target(
hass: HomeAssistant, config: dict[str, Any]
) -> set[str]:
"""Extract entities from target configuration."""
entities = set()
if "target" in config and isinstance(config["target"], dict):
target = config["target"]
for key in ("entity_id", "device_id", "area_id", "label_id"):
if key in target:
entities.update(await extract_entities_from_value(hass, target[key]))
return entities
def _get_action_service(config: dict[str, Any]) -> str | None:
"""Return the service/action name configured for an action."""
service = config.get("service", config.get("action"))
return service if isinstance(service, str) else None
def _should_skip_service_data_value(
service: str | None,
key: str,
) -> bool:
"""Return if a service data value should not be scanned for entity IDs."""
return service is not None and service.startswith("notify.") and key == "target"
async def _extract_entities_from_service_data(
hass: HomeAssistant, config: dict[str, Any]
) -> set[str]:
"""Extract entities from service data."""
entities = set()
if "data" in config:
data_value = config["data"]
if isinstance(data_value, str):
# data field is a template string itself
entities.update(await extract_entities_from_value(hass, data_value))
elif isinstance(data_value, dict):
service = _get_action_service(config)
# data field is a dictionary, process all its values
for key, value in data_value.items():
if _should_skip_service_data_value(service, key):
continue
entities.update(await extract_entities_from_value(hass, value))
return entities
async def _extract_entities_from_nested_configs(
hass: HomeAssistant, config: dict[str, Any]
) -> set[str]:
"""Extract entities from nested configurations."""
entities = set()
for value in config.values():
if isinstance(value, (dict, list)):
entities.update(await extract_entities_from_action_config(hass, value))
return entities
async def extract_entities_from_value(hass: HomeAssistant, value: Any) -> set[str]:
"""Extract entity IDs from a configuration value."""
entities = set()
if isinstance(value, str):
# Check if it's a template string using util.is_template_string
if is_template_string(value):
# Process as template to extract entity references
try:
template_entities = await async_extract_entities_from_template_string(
hass, value
entities.update(
await extract_entities_from_condition_config(
hass, value, known_services
)
entities.update(template_entities)
# pylint: disable-next=broad-exception-caught
except Exception as exc: # noqa: BLE001 - Keep broad for unexpected template issues
LOGGER.debug(
"Failed to extract entities from template: %s, error: %s",
value,
exc,
)
elif re.match(rf"^{ENTITY_ID_PATTERN}$", value):
# Check if it matches the entity ID pattern with known domains
entities.add(value)
elif isinstance(value, list):
for item in value:
entities.update(await extract_entities_from_value(hass, item))
elif (
isinstance(value, dict)
and "entity" in value
and isinstance(value["entity"], str)
):
# Handle entity dict format like {"entity": "light.living_room"}
entities.add(value["entity"])
)
return entities
@@ -324,9 +275,11 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
unavailable_entity_class = automation.UnavailableAutomationEntity
entity_label = "automation"
reference_label = "entities"
references_are_entities = True
edit_url_pattern = "/config/automation/edit/{unique_id}"
_known_entity_ids: set[str]
_known_services: set[str]
async def async_activate(self) -> None:
"""Activate the repair."""
@@ -354,10 +307,16 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
)
async def _async_setup_inspection(self) -> None:
"""Cache known entity IDs (including ALL/NONE) for this inspection cycle."""
"""Cache what every automation in this cycle needs looked up.
The service set is in here for the same reason as the entity ids:
building it flattens every service Home Assistant has, and it is the
same answer for every automation in one pass.
"""
self._known_entity_ids = async_get_all_entity_ids(
self.hass, include_all_none=True
)
self._known_services = async_get_all_services(self.hass)
def _should_inspect_entity(self, entity: Any) -> bool:
"""Skip disabled automations."""
@@ -371,7 +330,7 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
if hasattr(entity, "raw_config") and entity.raw_config:
all_entities.update(
await extract_entities_from_automation_config(
self.hass, entity.raw_config
self.hass, entity.raw_config, self._known_services
)
)
for key in ("trigger", "triggers"):
@@ -381,7 +340,9 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
# Extract entities from Template objects within the automation entity
all_entities.update(
await extract_template_entities_from_automation_entity(self.hass, entity)
await extract_template_entities_from_automation_entity(
self.hass, entity, self._known_services
)
)
return await async_filter_known_entity_ids_with_templates(
@@ -8,6 +8,7 @@ from homeassistant.components import automation
from homeassistant.helpers import floor_registry as fr
from ....entity_filtering import async_filter_known_floor_ids, async_get_all_floor_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
if TYPE_CHECKING:
@@ -37,8 +38,15 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return unknown floor IDs referenced by ``entity``."""
floor_ids = set(entity.referenced_floors)
# Also walk the raw configuration; the built-in extraction misses
# references nested in some step types, like repeat sequences.
if raw_config := getattr(entity, "raw_config", None):
floor_ids.update(extract_targets_from_config(raw_config).floor_ids)
return async_filter_known_floor_ids(
self.hass,
floor_ids=entity.referenced_floors,
floor_ids=floor_ids,
known_floor_ids=self._known_floor_ids,
)
@@ -8,6 +8,7 @@ from homeassistant.components import automation
from homeassistant.helpers import label_registry as lr
from ....entity_filtering import async_filter_known_label_ids, async_get_all_label_ids
from ....reference_extraction import extract_targets_from_config
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
if TYPE_CHECKING:
@@ -37,8 +38,15 @@ class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
"""Return unknown label IDs referenced by ``entity``."""
label_ids = set(entity.referenced_labels)
# Also walk the raw configuration; the built-in extraction misses
# references nested in some step types, like repeat sequences.
if raw_config := getattr(entity, "raw_config", None):
label_ids.update(extract_targets_from_config(raw_config).label_ids)
return async_filter_known_label_ids(
self.hass,
label_ids=entity.referenced_labels,
label_ids=label_ids,
known_label_ids=self._known_label_ids,
)
@@ -108,7 +108,7 @@ async def async_setup_entry(
class HomeAssistantCloudSpookSwitchEntity(HomeAssistantCloudSpookEntity, SwitchEntity):
"""Spook switch providig Home Asistant Cloud controls."""
"""Spook switch providing Home Assistant Cloud controls."""
entity_description: HomeAssistantCloudSpookSwitchEntityDescription
@@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import DATA_ENTITY_PLATFORM, EntityPl
from ....const import LOGGER
from ....entity_filtering import async_filter_known_entity_ids, async_get_all_entity_ids
from ....entity_suggestions import async_describe_unknown_entities
from ....repairs import AbstractSpookRepair
@@ -55,12 +56,19 @@ class SpookRepair(AbstractSpookRepair):
if unknown_entities := async_filter_known_entity_ids(
self.hass, entity_ids=members, known_entity_ids=known_entity_ids
):
described = async_describe_unknown_entities(
self.hass, sorted(unknown_entities)
)
self.async_create_issue(
issue_id=entity.entity_id,
is_fixable=True,
data={
"group_entity_id": entity.entity_id,
"group": entity.name,
"entities": described,
},
translation_placeholders={
"entities": "\n".join(
f"- `{entity_id}`" for entity_id in unknown_entities
),
"entities": described,
"group": entity.name,
"entity_id": entity.entity_id,
},
@@ -78,7 +78,7 @@ async def async_setup_entry(
class HomeAssistantSpookButtonEntity(HomeAssistantSpookEntity, ButtonEntity):
"""Spook button providig Home Asistant actions."""
"""Spook button providing Home Assistant actions."""
entity_description: HomeAssistantSpookButtonEntityDescription
@@ -2,38 +2,88 @@
from __future__ import annotations
from typing import Any
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from ...core_compat import (
async_get_child_devices_for_parent,
async_get_device_entries,
async_is_child_device,
async_update_any_device,
)
@callback
def _async_get_parent_device_id(device: Any) -> str | None:
"""Return the device a device hangs off, if it hangs off one at all.
A child device names its parent device, everything else names the device
it is connected through.
"""
if async_is_child_device(device):
return device.parent_device_id
return device.via_device_id
@callback
def _async_get_devices_hanging_off(
device_registry: dr.DeviceRegistry,
device_id: str,
) -> list[Any]:
"""Return the devices that hang off a device.
A device can be the parent of child devices and the device others are
connected through at the same time. Both hang off it.
"""
return [
*async_get_child_devices_for_parent(device_registry, device_id),
*(
device
for device in async_get_device_entries(device_registry)
if device.via_device_id == device_id
),
]
@callback
def async_disable_device_and_parent_if_needed(
device_registry: dr.DeviceRegistry,
device_id: str,
) -> None:
"""Disable a device and its parent when no enabled child devices remain."""
device = device_registry.async_get(device_id)
if device is None:
return
"""Disable a device and its parent when nothing enabled hangs off it."""
# A registry can contain via_device_id cycles between distinct devices,
# so track visited devices to avoid walking the chain forever.
seen: set[str] = set()
current_id: str | None = device_id
while current_id is not None and current_id not in seen:
seen.add(current_id)
device = device_registry.async_get(current_id)
if device is None:
return
if device.disabled_by is None:
device_registry.async_update_device(
device_id=device_id,
disabled_by=dr.DeviceEntryDisabler.USER,
)
if device.disabled_by is None:
async_update_any_device(
device_registry,
current_id,
disabled_by=dr.DeviceEntryDisabler.USER,
)
if device.via_device_id is None:
return
parent_id = _async_get_parent_device_id(device)
if parent_id is None:
return
if all(
child.id == device_id or child.disabled_by is not None
for child in device_registry.devices.values()
if child.via_device_id == device.via_device_id
):
async_disable_device_and_parent_if_needed(
device_registry,
device.via_device_id,
)
if not all(
hanging_device.id == current_id or hanging_device.disabled_by is not None
for hanging_device in _async_get_devices_hanging_off(
device_registry, parent_id
)
):
return
current_id = parent_id
@callback
@@ -42,14 +92,21 @@ def async_enable_device_and_parent(
device_id: str,
) -> None:
"""Enable a device and its parent device chain."""
device = device_registry.async_get(device_id)
if device is None:
return
# A registry can contain via_device_id cycles between distinct devices,
# so track visited devices to avoid walking the chain forever.
chain: list[str] = []
seen: set[str] = set()
current_id: str | None = device_id
while current_id is not None and current_id not in seen:
device = device_registry.async_get(current_id)
if device is None:
break
seen.add(current_id)
chain.append(current_id)
current_id = _async_get_parent_device_id(device)
if device.via_device_id is not None:
async_enable_device_and_parent(device_registry, device.via_device_id)
device_registry.async_update_device(
device_id=device_id,
disabled_by=None,
)
# Enable parents before their children, like the previous recursive walk.
# Home Assistant Core refuses to enable a child device while its parent is
# disabled, so the order is not just cosmetic.
for chain_device_id in reversed(chain):
async_update_any_device(device_registry, chain_device_id, disabled_by=None)
@@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Any
from homeassistant.components import (
automation,
counter,
group,
input_boolean,
input_button,
input_datetime,
@@ -17,6 +19,7 @@ from homeassistant.components import (
person,
script,
sun,
timer,
zone,
)
from homeassistant.components.sensor import (
@@ -165,6 +168,16 @@ SENSORS: tuple[HomeAssistantSpookSensorEntityDescription, ...] = (
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.CLIMATE)),
),
HomeAssistantSpookSensorEntityDescription(
key=counter.DOMAIN,
translation_key="homeassistant_counter",
entity_id="sensor.counters",
icon="mdi:counter",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(counter.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.COVER,
translation_key="homeassistant_cover",
@@ -237,6 +250,16 @@ SENSORS: tuple[HomeAssistantSpookSensorEntityDescription, ...] = (
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.FAN)),
),
HomeAssistantSpookSensorEntityDescription(
key=group.DOMAIN,
translation_key="homeassistant_group",
entity_id="sensor.groups",
icon="mdi:google-circles-communities",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(group.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.HUMIDIFIER,
translation_key="homeassistant_humidifier",
@@ -514,6 +537,16 @@ SENSORS: tuple[HomeAssistantSpookSensorEntityDescription, ...] = (
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(Platform.TIME)),
),
HomeAssistantSpookSensorEntityDescription(
key=timer.DOMAIN,
translation_key="homeassistant_timer",
entity_id="sensor.timers",
icon="mdi:timer-outline",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
update_events={EVENT_COMPONENT_LOADED, er.EVENT_ENTITY_REGISTRY_UPDATED},
value_fn=lambda hass: len(hass.states.async_entity_ids(timer.DOMAIN)),
),
HomeAssistantSpookSensorEntityDescription(
key=Platform.TODO,
translation_key="homeassistant_todo",
@@ -599,7 +632,7 @@ async def async_setup_entry(
class HomeAssistantSpookSensorEntity(HomeAssistantSpookEntity, SensorEntity):
"""Spook sensor providig Home Asistant information."""
"""Spook sensor providing Home Assistant information."""
entity_description: HomeAssistantSpookSensorEntityDescription
_unsub_debouncer: Callable[[], None] | None = None
@@ -1 +1,56 @@
"""Spook - Your homie."""
from __future__ import annotations
from typing import TYPE_CHECKING
import voluptuous as vol
# Home Assistant keeps the exposure settings, and the list of assistants that
# have them, in this module. There is no public helper for either.
from homeassistant.components.homeassistant.exposed_entities import (
KNOWN_ASSISTANTS,
async_expose_entity,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant, ServiceCall
CONF_ASSISTANTS = "assistants"
EXPOSURE_SERVICE_SCHEMA = {
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(CONF_ASSISTANTS): vol.All(cv.ensure_list, [vol.In(KNOWN_ASSISTANTS)]),
}
def async_set_voice_assistant_exposure(
hass: HomeAssistant,
call: ServiceCall,
*,
should_expose: bool,
) -> None:
"""Set whether entities are exposed to the given voice assistants.
Shared by the expose and unexpose actions, which differ only in what they
set it to.
"""
entity_registry = er.async_get(hass)
# Checked up front, so an unknown entity halfway down the list does not
# leave the ones before it already changed, with nothing saying which.
entity_ids = call.data[ATTR_ENTITY_ID]
for entity_id in entity_ids:
if (
hass.states.get(entity_id) is None
and entity_registry.async_get(entity_id) is None
):
msg = f"Unknown entity: {entity_id}"
raise HomeAssistantError(msg)
for entity_id in entity_ids:
for assistant in call.data[CONF_ASSISTANTS]:
async_expose_entity(hass, assistant, entity_id, should_expose=should_expose)
@@ -14,6 +14,7 @@ from homeassistant.helpers import (
device_registry as dr,
)
from ....core_compat import async_update_any_device
from ....services import AbstractSpookAdminService
if TYPE_CHECKING:
@@ -39,7 +40,8 @@ class SpookService(AbstractSpookAdminService):
device_registry = dr.async_get(self.hass)
for device_id in call.data["device_id"]:
device_registry.async_update_device(
async_update_any_device(
device_registry,
device_id,
area_id=call.data["area_id"],
)
@@ -11,10 +11,10 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
area_registry as ar,
config_validation as cv,
label_registry as lr,
)
from ....services import AbstractSpookAdminService
from ..labels import async_check_labels_exist
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@@ -32,15 +32,14 @@ class SpookService(AbstractSpookAdminService):
async def async_handle_service(self, call: ServiceCall) -> None:
"""Handle the service call."""
label_registry = lr.async_get(self.hass)
for label_id in call.data["label_id"]:
if not label_registry.async_get_label(label_id):
msg = f"Label {label_id} not found"
raise HomeAssistantError(msg)
async_check_labels_exist(self.hass, call.data["label_id"])
area_registry = ar.async_get(self.hass)
for area_id in call.data["area_id"]:
if area_entry := area_registry.async_get_area(area_id):
labels = area_entry.labels.copy()
labels.update(call.data["label_id"])
area_registry.async_update(area_id, labels=labels)
if (area_entry := area_registry.async_get_area(area_id)) is None:
msg = f"Area {area_id} not found"
raise HomeAssistantError(msg)
labels = area_entry.labels.copy()
labels.update(call.data["label_id"])
area_registry.async_update(area_id, labels=labels)
@@ -11,10 +11,11 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
label_registry as lr,
)
from ....core_compat import async_update_any_device
from ....services import AbstractSpookAdminService
from ..labels import async_check_labels_exist
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@@ -32,15 +33,14 @@ class SpookService(AbstractSpookAdminService):
async def async_handle_service(self, call: ServiceCall) -> None:
"""Handle the service call."""
label_registry = lr.async_get(self.hass)
for label_id in call.data["label_id"]:
if not label_registry.async_get_label(label_id):
msg = f"Label {label_id} not found"
raise HomeAssistantError(msg)
async_check_labels_exist(self.hass, call.data["label_id"])
device_registry = dr.async_get(self.hass)
for device_id in call.data["device_id"]:
if device_entry := device_registry.async_get(device_id):
labels = device_entry.labels.copy()
labels.update(call.data["label_id"])
device_registry.async_update_device(device_id, labels=labels)
if (device_entry := device_registry.async_get(device_id)) is None:
msg = f"Device {device_id} not found"
raise HomeAssistantError(msg)
labels = device_entry.labels.copy()
labels.update(call.data["label_id"])
async_update_any_device(device_registry, device_id, labels=labels)
@@ -11,10 +11,10 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
entity_registry as er,
label_registry as lr,
)
from ....services import AbstractSpookAdminService
from ..labels import async_check_labels_exist
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@@ -32,15 +32,14 @@ class SpookService(AbstractSpookAdminService):
async def async_handle_service(self, call: ServiceCall) -> None:
"""Handle the service call."""
label_registry = lr.async_get(self.hass)
for label_id in call.data["label_id"]:
if not label_registry.async_get_label(label_id):
msg = f"Label {label_id} not found"
raise HomeAssistantError(msg)
async_check_labels_exist(self.hass, call.data["label_id"])
entity_registry = er.async_get(self.hass)
for entity_id in call.data["entity_id"]:
if entity_entry := entity_registry.async_get(entity_id):
labels = entity_entry.labels.copy()
labels.update(call.data["label_id"])
entity_registry.async_update_entity(entity_id, labels=labels)
if (entity_entry := entity_registry.async_get(entity_id)) is None:
msg = f"Entity {entity_id} not found"
raise HomeAssistantError(msg)
labels = entity_entry.labels.copy()
labels.update(call.data["label_id"])
entity_registry.async_update_entity(entity_id, labels=labels)
@@ -4,18 +4,31 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import create_engine, text
from sqlalchemy import text
from homeassistant.components.homeassistant import DOMAIN
from homeassistant.components.recorder import (
get_instance,
)
from homeassistant.components.recorder import get_instance
from homeassistant.core import ServiceResponse, SupportsResponse
from homeassistant.helpers.recorder import session_scope
from ....services import AbstractSpookService
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall
_RECORDED_ENTITY_IDS = text("SELECT DISTINCT(entity_id) FROM states_meta")
def _recorded_entity_ids(hass: HomeAssistant) -> set[str]:
"""Return every entity ID the recorder has ever written down.
Runs on the recorder's own thread, on the connection it already has.
Opening a second engine here would put the whole query on the event loop,
where a large or remote database stalls everything else in the house
while it reads, and would leave a connection pool behind afterwards.
"""
with session_scope(hass=hass, read_only=True) as session:
return {row[0] for row in session.execute(_RECORDED_ENTITY_IDS)}
class SpookService(AbstractSpookService):
@@ -27,21 +40,14 @@ class SpookService(AbstractSpookService):
async def async_handle_service(self, call: ServiceCall) -> ServiceResponse:
"""Handle the service call."""
query = text(
"""
SELECT DISTINCT(entity_id) FROM states_meta
"""
recorded = await get_instance(self.hass).async_add_executor_job(
_recorded_entity_ids, self.hass
)
db_url = get_instance(self.hass).db_url
engine = create_engine(db_url)
with engine.connect() as conn:
response = conn.execute(query)
db_list = [e[0] for e in response]
states_list = self.hass.states.async_entity_ids()
compared_list = set(db_list).difference(states_list)
orphaned = recorded.difference(self.hass.states.async_entity_ids())
if call.return_response:
return {
"count": len(compared_list),
"entities": list(compared_list),
"count": len(orphaned),
"entities": list(orphaned),
}
return None
@@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.components.homeassistant import DOMAIN
from homeassistant.helpers import config_validation as cv, device_registry as dr
from ....core_compat import async_update_any_device
from ....services import AbstractSpookAdminService
if TYPE_CHECKING:
@@ -28,7 +29,8 @@ class SpookService(AbstractSpookAdminService):
"""Handle the service call."""
device_registry = dr.async_get(self.hass)
for device_id in call.data["device_id"]:
device_registry.async_update_device(
async_update_any_device(
device_registry,
device_id,
area_id=None,
)
@@ -7,9 +7,14 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.homeassistant import DOMAIN
from homeassistant.helpers import area_registry as ar, config_validation as cv
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
area_registry as ar,
config_validation as cv,
)
from ....services import AbstractSpookAdminService
from ..labels import async_check_labels_exist
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@@ -27,9 +32,14 @@ class SpookService(AbstractSpookAdminService):
async def async_handle_service(self, call: ServiceCall) -> None:
"""Handle the service call."""
async_check_labels_exist(self.hass, call.data["label_id"])
area_registry = ar.async_get(self.hass)
for area_id in call.data["area_id"]:
if area_entry := area_registry.async_get_area(area_id):
labels = area_entry.labels.copy()
labels.difference_update(call.data["label_id"])
area_registry.async_update(area_id, labels=labels)
if (area_entry := area_registry.async_get_area(area_id)) is None:
msg = f"Area {area_id} not found"
raise HomeAssistantError(msg)
labels = area_entry.labels.copy()
labels.difference_update(call.data["label_id"])
area_registry.async_update(area_id, labels=labels)
@@ -7,9 +7,15 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.homeassistant import DOMAIN
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
)
from ....core_compat import async_update_any_device
from ....services import AbstractSpookAdminService
from ..labels import async_check_labels_exist
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@@ -27,9 +33,14 @@ class SpookService(AbstractSpookAdminService):
async def async_handle_service(self, call: ServiceCall) -> None:
"""Handle the service call."""
async_check_labels_exist(self.hass, call.data["label_id"])
device_registry = dr.async_get(self.hass)
for device_id in call.data["device_id"]:
if device_entry := device_registry.async_get(device_id):
labels = device_entry.labels.copy()
labels.difference_update(call.data["label_id"])
device_registry.async_update_device(device_id, labels=labels)
if (device_entry := device_registry.async_get(device_id)) is None:
msg = f"Device {device_id} not found"
raise HomeAssistantError(msg)
labels = device_entry.labels.copy()
labels.difference_update(call.data["label_id"])
async_update_any_device(device_registry, device_id, labels=labels)
@@ -7,9 +7,14 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.homeassistant import DOMAIN
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
entity_registry as er,
)
from ....services import AbstractSpookAdminService
from ..labels import async_check_labels_exist
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
@@ -27,9 +32,14 @@ class SpookService(AbstractSpookAdminService):
async def async_handle_service(self, call: ServiceCall) -> None:
"""Handle the service call."""
async_check_labels_exist(self.hass, call.data["label_id"])
entity_registry = er.async_get(self.hass)
for entity_id in call.data["entity_id"]:
if entity_entry := entity_registry.async_get(entity_id):
labels = entity_entry.labels.copy()
labels.difference_update(call.data["label_id"])
entity_registry.async_update_entity(entity_id, labels=labels)
if (entity_entry := entity_registry.async_get(entity_id)) is None:
msg = f"Entity {entity_id} not found"
raise HomeAssistantError(msg)
labels = entity_entry.labels.copy()
labels.difference_update(call.data["label_id"])
entity_registry.async_update_entity(entity_id, labels=labels)
@@ -1 +1,81 @@
"""Spook - Your homie."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
if TYPE_CHECKING:
from homeassistant.components.input_number import InputNumber
from homeassistant.core import ServiceCall
CONF_AMOUNT = "amount"
CONF_CYCLE = "cycle"
# Steps are floats, so every comparison against a step boundary needs slack.
_FLOAT_TOLERANCE = 1e-9
STEP_SERVICE_SCHEMA = {
vol.Optional(CONF_AMOUNT): vol.Coerce(float),
vol.Optional(CONF_CYCLE, default=False): cv.boolean,
}
def native_value_as_float(entity: InputNumber) -> float:
"""Return the current value of an input number as a float."""
try:
return float(entity.native_value)
except (TypeError, ValueError) as err:
msg = f"Value {entity.native_value!r} for {entity.entity_id} is not a number"
raise HomeAssistantError(msg) from err
def step_amount(entity: InputNumber, call: ServiceCall) -> float:
"""Return the requested amount, checked against the entity's step."""
step = entity.native_step
amount = call.data.get(CONF_AMOUNT, step)
remainder = amount % step
# Float modulo wraps around just short of the step, 0.3 % 0.1 is
# 0.09999999999999998, so a remainder against either end is a clean multiple.
if not (
math.isclose(remainder, 0, rel_tol=0, abs_tol=_FLOAT_TOLERANCE)
or math.isclose(remainder, step, rel_tol=0, abs_tol=_FLOAT_TOLERANCE)
):
msg = (
f"Amount {amount} not valid for {entity.entity_id}, "
f"it needs to be a multiple of {step}"
)
raise ValueError(msg)
return amount
def cycled_value(entity: InputNumber, value: float) -> float:
"""Wrap a value around the entity's range.
Home Assistant cycles a select by taking the new index modulo the number
of options. The same thing here means the positions on the step grid from
the minimum to the maximum, so overshooting one end carries on from the
other rather than landing on it. Stepping one past the maximum reaches the
minimum, and stepping five past it reaches the fifth value up.
"""
minimum = entity.native_min_value
step = entity.native_step
# Floored, because the step does not have to divide the range. Rounding
# would invent a slot above the maximum: a step of 0.6 across 0 to 1 would
# offer 1.2, and setting that raises. The tolerance keeps a quotient that
# is a whole number in spirit, like 10 steps of 0.1, from flooring down.
quotient = (entity.native_max_value - minimum) / step
slots = math.floor(quotient + _FLOAT_TOLERANCE) + 1
# Counted in whole steps, so float drift cannot push the result into the
# neighbouring slot on the way through the modulo.
offset = round((value - minimum) / step)
return minimum + (offset % slots) * step

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