"""Device discovery over the assembled signature catalog.""" from __future__ import annotations from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar from homeassistant.helpers import device_registry as dr from homeassistant.helpers import entity_registry as er from ._model import ( _entity_matches, _entity_unit, _threshold_for, _unit_compatible, task_name_variants, ) from ._registry import SIGNATURES def _entity_watchers(hass: HomeAssistant) -> dict[str, set[str]]: """entity_id → lowercased names of the tasks watching it via a trigger.""" from ...const import CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID from ...entity.triggers import normalize_entity_ids out: dict[str, set[str]] = {} for entry in hass.config_entries.async_entries(DOMAIN): if entry.unique_id == GLOBAL_UNIQUE_ID: continue for task in entry.data.get(CONF_TASKS, {}).values(): tc = task.get("trigger_config") if isinstance(tc, dict): name = str(task.get("name", "")).lower() for eid in normalize_entity_ids(tc): out.setdefault(eid, set()).add(name) return out def _catalog_name_variants() -> set[str]: """Every catalog task name in every language, lowercased — to recognise whether an existing watcher task is one of OUR duties or a custom one.""" variants: set[str] = set() for catalog in SIGNATURES.values(): for sig in catalog.tasks: variants.update(task_name_variants(sig.task_name)) return variants def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]: """Devices of catalogued integrations with their matchable task wiring. Groups matched entities per device; carries the maintenance object already attached to the device (if any) so adoption can extend it instead of creating a duplicate. Entity claims are per DUTY, not per entity: a task already watching an entity blocks only its own duty (recognised by catalog name in any language), so a mower's hours counter still proposes "Clean Undercarriage" after "Replace Mower Blades" was adopted. A watcher with a custom/renamed name conservatively claims the whole entity — re-running discovery never re-proposes against a rename. """ from ...templates import localize_template_text from ..i18n import normalize_language from ..problem_sensors import _object_by_device lang = normalize_language(hass) ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) area_reg = ar.async_get(hass) watchers = _entity_watchers(hass) known_variants = _catalog_name_variants() by_device = _object_by_device(hass) # Collect the enabled registry entities of cataloged integrations per # (device, integration) first — the device-type gates need the device's # FULL entity list (siblings identify the appliance type). by_device_integration: dict[tuple[str, str], list[er.RegistryEntry]] = {} for entry in ent_reg.entities.values(): if SIGNATURES.get(entry.platform) is None or not entry.device_id: continue if entry.disabled_by is not None: continue by_device_integration.setdefault((entry.device_id, entry.platform), []).append(entry) # device_id → {(integration, task_name, direction): {sig, entity_ids}}. # The direction is part of the key so an integration that ships one task # name in two directions (LG ThinQ filter: hours vs percent) stays split. matched: dict[str, dict[tuple[str, str, str], dict[str, Any]]] = {} for (device_id, integration), entries in by_device_integration.items(): catalog = SIGNATURES[integration] device = dev_reg.async_get(device_id) model = ((device.model or "") if device else "").lower() for sig in catalog.tasks: # Device-type gates: registry model substring and/or a # type-identifying sibling entity (watched siblings still count — # only the match TARGET must be unwatched). if sig.models and not any(m.lower() in model for m in sig.models): continue if sig.models_exclude and any(m.lower() in model for m in sig.models_exclude): continue if sig.require_sibling_keys and not any( any(_entity_matches(e, key) for key in sig.require_sibling_keys) for e in entries ): continue variants = task_name_variants(sig.task_name) for entry in entries: if entry.domain != sig.entity_domain: continue # Per-duty claims: a watcher task named as THIS duty (any # language) blocks it; watchers named as other catalog duties # leave the remaining duties adoptable; a custom/renamed # watcher claims the whole entity. watcher_names = watchers.get(entry.entity_id) if watcher_names and (watcher_names & variants or watcher_names - known_variants): continue if not _unit_compatible(sig.direction, _entity_unit(hass, entry)): continue # Empty keys (non-sensor domains only, tripwire-enforced) match # the device's single entity of that domain — THE lawn_mower. if sig.keys and not any(_entity_matches(entry, key) for key in sig.keys): continue group = matched.setdefault(device_id, {}).setdefault( (integration, sig.task_name, sig.direction), {"sig": sig, "entity_ids": []}, ) group["entity_ids"].append(entry.entity_id) # One source entity may back SEVERAL duties (a mower's hours # counter drives blades AND undercarriage) — both within one # run and across runs: the per-duty claim above keeps a # deselected duty proposable after its sibling was adopted. out: list[dict[str, Any]] = [] for device_id, sig_map in matched.items(): device = dev_reg.async_get(device_id) if device is None: continue device_name = device.name_by_user or device.name or device_id area_name = "" if device.area_id and (area := area_reg.async_get_area(device.area_id)): area_name = area.name integration = next(iter(sig_map))[0] catalog = SIGNATURES[integration] suggested = by_device.get(device_id) # Duties already present on the bound object BY NAME (any language) are # not re-proposed — covers manually created calendar tasks whose # trigger watches no entity (the entity-watched exclusion misses them). existing_names: set[str] = set() if suggested and (target := hass.config_entries.async_get_entry(suggested["entry_id"])): from ...const import CONF_TASKS existing_names = {str(t.get("name", "")).lower() for t in target.data.get(CONF_TASKS, {}).values()} tasks = [] for (_integ, task_name, direction), group in sig_map.items(): entity_ids = sorted(group["entity_ids"]) if not entity_ids: continue if existing_names and existing_names & task_name_variants(task_name): continue tasks.append( { # task_name stays the EN catalog key (adopt selections # match on it); the dialog renders the localized twin. "task_name": task_name, "task_name_localized": localize_template_text(task_name, lang) or task_name, "entity_ids": entity_ids, "threshold": _threshold_for(group["sig"], hass, entity_ids[0]), "direction": direction, } ) if not tasks: continue tasks.sort(key=lambda t: (t["task_name"], t["direction"])) out.append( { "device_id": device_id, "device_name": device_name, "area_name": area_name, "integration": integration, "integration_name": catalog.name, "suggested_entry_id": suggested["entry_id"] if suggested else None, "suggested_object_name": suggested["name"] if suggested else device_name, "tasks": tasks, } ) out.sort(key=lambda s: (s["integration_name"], s["device_name"])) return out