Files
Home-Assistant/custom_components/powercalc/discovery.py
T
2026-06-15 10:53:52 -04:00

606 lines
24 KiB
Python

from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from enum import StrEnum
import logging
import re
from typing import Any, TypeVar
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER, ConfigEntry
from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_UNIQUE_ID
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import discovery_flow
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.entity import EntityCategory
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .common import SourceEntity, create_source_entity
from .const import (
CONF_MANUFACTURER,
CONF_MODE,
CONF_MODEL,
CONF_SENSORS,
DATA_DISCOVERY_MANAGER,
DISCOVERY_POWER_PROFILES,
DISCOVERY_SOURCE_ENTITY,
DOMAIN,
DUMMY_ENTITY_ID,
MANUFACTURER_WLED,
CalculationStrategy,
)
from .group_include.filter import (
CategoryFilter,
CompositeFilter,
DomainFilter,
FilterOperator,
LambdaFilter,
NotFilter,
get_filtered_entity_list,
)
from .helpers import get_or_create_unique_id
from .power_profile.factory import get_power_profile
from .power_profile.library import ModelInfo, ProfileLibrary
from .power_profile.power_profile import SUPPORTED_DOMAINS, DeviceType, DiscoveryBy, PowerProfile
_LOGGER = logging.getLogger(__name__)
_DiscoverySourceT = TypeVar("_DiscoverySourceT", er.RegistryEntry, dr.DeviceEntry)
async def get_power_profile_by_source_entity(hass: HomeAssistant, source_entity: SourceEntity) -> PowerProfile | None:
"""Given a certain entity, lookup the manufacturer and model and return the power profile."""
try:
discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER]
except KeyError:
discovery_manager = DiscoveryManager(hass, {})
model_info = await discovery_manager.extract_model_info_from_device_info(source_entity.entity_entry)
if not model_info:
return None
profiles = await discovery_manager.find_power_profiles(model_info, source_entity, DiscoveryBy.ENTITY)
return profiles[0] if profiles else None
async def get_power_profile_by_source_device(hass: HomeAssistant, source_entity: SourceEntity) -> PowerProfile | None:
"""Look up a device-discovered power profile for a source entity's device."""
if not source_entity.device_entry or not source_entity.entity_entry:
return None
try:
discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER]
except KeyError:
discovery_manager = DiscoveryManager(hass, {})
model_info = await discovery_manager.extract_model_info_from_device_info(source_entity.entity_entry)
if not model_info:
return None
profiles = await discovery_manager.find_power_profiles(model_info, source_entity, DiscoveryBy.DEVICE)
return profiles[0] if profiles else None
class DiscoveryStatus(StrEnum):
DISABLED = "disabled"
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
FINISHED = "finished"
class DiscoveryManager:
"""This class is responsible for scanning the HA instance for entities and their manufacturer / model info
It checks if any of these devices is supported in the powercalc library
When entities are found it will dispatch a discovery flow, so the user can add them to their HA instance.
"""
def __init__(
self,
hass: HomeAssistant,
ha_config: ConfigType,
exclude_device_types: list[DeviceType] | None = None,
exclude_self_usage_profiles: bool = False,
enabled: bool = True,
) -> None:
self.hass = hass
self.ha_config = ha_config
self.power_profiles: dict[str, PowerProfile | None] = {}
self.manually_configured_entities: list[str] | None = None
self.initialized_flows: set[str] = set()
self.library: ProfileLibrary | None = None
self._exclude_device_types = exclude_device_types or []
self._exclude_self_usage_profiles = exclude_self_usage_profiles or False
self._cancel_rediscover_interval: CALLBACK_TYPE | None = None
self._status = DiscoveryStatus.NOT_STARTED if enabled else DiscoveryStatus.DISABLED
async def setup(self) -> None:
"""Setup the discovery manager. Start initial discovery and setup interval based rediscovery."""
if self._status == DiscoveryStatus.DISABLED:
_LOGGER.debug("Discovery manager is disabled, skipping setup")
return
await self.start_discovery()
async def _rediscover(_: datetime) -> None:
"""Rediscover entities."""
await self.update_library_and_rediscover()
if self._cancel_rediscover_interval: # pragma: no cover
self._cancel_rediscover_interval()
self._cancel_rediscover_interval = async_track_time_interval(
self.hass,
_rediscover,
timedelta(hours=2),
cancel_on_shutdown=True,
)
async def update_library_and_rediscover(self) -> None:
"""Update the library and rediscover entities."""
library = await self._get_library()
await library.initialize()
await self.start_discovery()
async def start_discovery(self) -> None:
"""Start the discovery procedure."""
if self._status == DiscoveryStatus.DISABLED:
_LOGGER.debug("Discovery manager is disabled, skipping discovery run")
return
if self._status == DiscoveryStatus.IN_PROGRESS:
_LOGGER.debug("Discovery already in progress, skipping new discovery run")
return
self._status = DiscoveryStatus.IN_PROGRESS
await self.initialize_existing_entries()
_LOGGER.debug("Start auto discovery")
_LOGGER.debug("Start entity discovery")
await self.perform_discovery(self.get_entities, self.create_entity_source, DiscoveryBy.ENTITY)
_LOGGER.debug("Start device discovery")
await self.perform_discovery(self.get_devices, self.create_device_source, DiscoveryBy.DEVICE)
_LOGGER.debug("Done auto discovery")
self._status = DiscoveryStatus.FINISHED
async def initialize_existing_entries(self) -> None:
"""Build a list of config entries which are already setup, to prevent duplicate discovery flows"""
for entry in self.hass.config_entries.async_entries(DOMAIN):
if not entry.unique_id:
continue # pragma: no cover
self.initialized_flows.add(entry.unique_id)
entity_id = entry.data.get(CONF_ENTITY_ID)
if not entity_id or entity_id == DUMMY_ENTITY_ID:
continue
entity = create_source_entity(str(entity_id), self.hass)
if entity and entity.device_entry:
self.initialized_flows.add(f"pc_{entity.device_entry.id}")
self.initialized_flows.add(entity_id)
def remove_initialized_flow(self, entry: ConfigEntry) -> None:
"""Remove a flow from the initialized flows."""
if entry.unique_id:
self.initialized_flows.discard(entry.unique_id)
entity_id = entry.data.get(CONF_ENTITY_ID)
if entity_id:
self.initialized_flows.discard(entity_id)
async def perform_discovery(
self,
source_provider: Callable[[], Awaitable[list[_DiscoverySourceT]]],
source_creator: Callable[[_DiscoverySourceT], Awaitable[SourceEntity]],
discovery_type: DiscoveryBy,
) -> None:
"""Generalized discovery procedure for entities and devices."""
for source in await source_provider():
log_identifier = str(getattr(source, "entity_id", getattr(source, "id", "unknown")))
try:
model_info = await self.extract_model_info_from_device_info(source)
if not model_info:
continue
source_entity = await source_creator(source)
power_profiles = await self.discover_entity(source_entity, model_info, discovery_type)
if not power_profiles:
_LOGGER.debug("%s: Model not found in library, skipping discovery", log_identifier)
continue
unique_id = self.create_unique_id(
source_entity,
discovery_type,
power_profiles[0] if power_profiles else None,
)
if self._is_already_discovered(source_entity, unique_id):
_LOGGER.debug(
"%s: Already setup with discovery, skipping new discovery (unique_id=%s)",
log_identifier,
unique_id,
)
continue
self._init_entity_discovery(model_info, unique_id, source_entity, log_identifier, power_profiles, {})
except Exception as err: # noqa: BLE001
_LOGGER.error(
"%s: Error during %s discovery: %s",
log_identifier,
discovery_type,
err,
)
async def discover_entity(
self,
source_entity: SourceEntity,
model_info: ModelInfo,
discovery_type: DiscoveryBy = DiscoveryBy.ENTITY,
) -> list[PowerProfile] | None:
if source_entity.entity_entry and self.is_wled_light(model_info, source_entity.entity_entry):
await self.init_wled_flow(model_info, source_entity)
return None
return await self.find_power_profiles(model_info, source_entity, discovery_type)
async def create_entity_source(self, entity_entry: er.RegistryEntry) -> SourceEntity:
"""Create SourceEntity for an entity."""
return create_source_entity(entity_entry.entity_id, self.hass)
@staticmethod
async def create_device_source(device_entry: dr.DeviceEntry) -> SourceEntity:
"""Create SourceEntity for a device."""
return SourceEntity(
object_id=device_entry.name_by_user or device_entry.name or "",
name=device_entry.name,
entity_id=DUMMY_ENTITY_ID,
domain="sensor",
device_entry=device_entry,
)
@staticmethod
def create_unique_id(source: SourceEntity, discovery_type: DiscoveryBy, power_profile: PowerProfile | None) -> str:
"""Generate a unique ID based on source and type."""
if discovery_type == DiscoveryBy.DEVICE:
device_id = source.object_id
if source.device_entry:
device_id = source.device_entry.id
return f"pc_{device_id}"
return get_or_create_unique_id({}, source, power_profile)
async def find_power_profiles(
self,
model_info: ModelInfo,
source_entity: SourceEntity,
discovery_type: DiscoveryBy,
) -> list[PowerProfile] | None:
"""Find power profiles for a given entity."""
library = await self._get_library()
models = await library.find_models(model_info)
if not models:
return None
power_profiles = []
for model_info in models:
profile = await get_power_profile(
self.hass,
{},
source_entity,
model_info=model_info,
process_variables=False,
)
if not profile or profile.discovery_by != discovery_type: # pragma: no cover
continue
if discovery_type == DiscoveryBy.ENTITY:
entity_entry = source_entity.entity_entry
if entity_entry is not None and not profile.is_entity_domain_supported(entity_entry):
continue
if profile.device_type in self._exclude_device_types:
continue
if self._exclude_self_usage_profiles and profile.only_self_usage:
continue
# Check if the entity's integration is compatible with the profile
if (
discovery_type == DiscoveryBy.ENTITY
and source_entity.entity_entry
and profile.compatible_integrations
and source_entity.entity_entry.platform not in profile.compatible_integrations
):
continue
power_profiles.append(profile)
return power_profiles
async def init_wled_flow(self, model_info: ModelInfo, source_entity: SourceEntity) -> None:
"""Initialize the discovery flow for a WLED light."""
if DeviceType.LIGHT in self._exclude_device_types:
return
unique_id = (
f"pc_{source_entity.device_entry.id}"
if source_entity.device_entry
else get_or_create_unique_id({}, source_entity, None)
)
if self._is_already_discovered(source_entity, unique_id):
_LOGGER.debug(
"%s: Already setup with discovery, skipping new discovery (unique_id=%s)",
source_entity.entity_id,
unique_id,
)
return
self._init_entity_discovery(
model_info,
unique_id,
source_entity,
source_entity.entity_id,
power_profiles=None,
extra_discovery_data={
CONF_MODE: CalculationStrategy.WLED,
},
)
@staticmethod
def is_wled_light(model_info: ModelInfo, entity_entry: er.RegistryEntry) -> bool:
"""Check if the entity is a WLED light."""
return (
model_info.manufacturer == MANUFACTURER_WLED
and entity_entry.domain == LIGHT_DOMAIN
and not re.search("master|segment", str(entity_entry.original_name), flags=re.IGNORECASE)
and not re.search("master|segment", str(entity_entry.entity_id), flags=re.IGNORECASE)
)
async def get_entities(self) -> list[er.RegistryEntry]:
"""Get all entities from entity registry which qualifies for discovery."""
def _check_already_configured(entity: er.RegistryEntry) -> bool:
has_user_config = self._is_user_configured(entity.entity_id)
if has_user_config:
_LOGGER.debug(
"%s: Entity is manually configured, skipping auto configuration",
entity.entity_id,
)
return has_user_config
entity_filter = CompositeFilter(
[
CategoryFilter(
[
EntityCategory.CONFIG,
EntityCategory.DIAGNOSTIC,
],
),
LambdaFilter(_check_already_configured),
LambdaFilter(lambda entity: entity.device_id is None),
LambdaFilter(lambda entity: entity.platform == "mqtt" and "segment" in entity.entity_id),
LambdaFilter(lambda entity: entity.platform in ["powercalc", "switch_as_x"]),
NotFilter(DomainFilter(SUPPORTED_DOMAINS)),
],
FilterOperator.OR,
)
return get_filtered_entity_list(self.hass, NotFilter(entity_filter))
async def get_devices(self) -> list[dr.DeviceEntry]:
"""Fetch device entries."""
return list(dr.async_get(self.hass).devices.values())
def enable(self) -> None:
"""Enable the discovery."""
self._status = DiscoveryStatus.NOT_STARTED
async def disable(self) -> None:
"""Disable the discovery."""
if self._cancel_rediscover_interval:
self._cancel_rediscover_interval()
self._cancel_rediscover_interval = None
self._status = DiscoveryStatus.DISABLED
self.initialized_flows = set()
flows = self.hass.config_entries.flow.async_progress_by_handler(DOMAIN)
for flow in flows:
if flow["context"]["source"] != SOURCE_INTEGRATION_DISCOVERY:
continue # pragma: no cover
self.hass.config_entries.flow.async_abort(flow["flow_id"])
return
async def extract_model_info_from_device_info(
self,
entry: er.RegistryEntry | dr.DeviceEntry | None,
) -> ModelInfo | None:
"""Try to auto discover manufacturer and model from the known device information."""
if not entry:
return None
log_identifier = entry.entity_id if isinstance(entry, er.RegistryEntry) else entry.id
if isinstance(entry, er.RegistryEntry):
model_info = await self.get_model_information_from_entity(entry)
else:
model_info = await self.get_model_information_from_device(entry)
if not model_info:
_LOGGER.debug(
"%s: Cannot autodiscover model, manufacturer or model unknown from device registry",
log_identifier,
)
return None
# Make sure we don't have a literal / in model_id,
# so we don't get issues with sublut directory matching down the road
# See github #658
if "/" in model_info.model:
model_info = ModelInfo(
model_info.manufacturer,
model_info.model.replace("/", "#slash#"),
model_info.model_id,
)
_LOGGER.debug(
"%s: Found model information on device (manufacturer=%s, model=%s, model_id=%s)",
log_identifier,
model_info.manufacturer,
model_info.model,
model_info.model_id,
)
return model_info
@staticmethod
async def get_model_information_from_device(device_entry: dr.DeviceEntry) -> ModelInfo | None:
"""See if we have enough information in device registry to automatically set up the power sensor."""
if device_entry.manufacturer is None or device_entry.model is None:
return None
# Strip whitespace: some integrations include trailing spaces
# see https://github.com/home-assistant/core/pull/166187
manufacturer = str(device_entry.manufacturer).strip()
model = str(device_entry.model).strip()
model_id = (
str(device_entry.model_id).strip() if hasattr(device_entry, "model_id") and device_entry.model_id else None
)
if len(manufacturer) == 0 or len(model) == 0:
return None
return ModelInfo(manufacturer, model, model_id)
async def get_model_information_from_entity(self, entity_entry: er.RegistryEntry) -> ModelInfo | None:
"""See if we have enough information in device registry to automatically setup the power sensor."""
if entity_entry.device_id is None:
return None
device_registry = dr.async_get(self.hass)
device_entry = device_registry.async_get(entity_entry.device_id)
if device_entry is None:
return None
return await self.get_model_information_from_device(device_entry)
@callback
def _init_entity_discovery(
self,
model_info: ModelInfo,
unique_id: str,
source_entity: SourceEntity,
log_identifier: str,
power_profiles: list[PowerProfile] | None,
extra_discovery_data: dict | None,
) -> None:
"""Dispatch the discovery flow for a given entity."""
discovery_data: dict[str, Any] = {
CONF_ENTITY_ID: source_entity.entity_id,
DISCOVERY_SOURCE_ENTITY: source_entity,
CONF_UNIQUE_ID: unique_id,
}
if power_profiles:
discovery_data[DISCOVERY_POWER_PROFILES] = power_profiles
if len(power_profiles) == 1:
power_profile = power_profiles[0]
discovery_data[CONF_MANUFACTURER] = power_profile.manufacturer
discovery_data[CONF_MODEL] = power_profile.model
if CONF_MANUFACTURER not in discovery_data:
discovery_data[CONF_MANUFACTURER] = model_info.manufacturer
if CONF_MODEL not in discovery_data:
discovery_data[CONF_MODEL] = model_info.model or model_info.model_id
if extra_discovery_data:
discovery_data.update(extra_discovery_data)
self.initialized_flows.add(unique_id)
if source_entity.entity_id != DUMMY_ENTITY_ID:
self.initialized_flows.add(source_entity.entity_id)
_LOGGER.debug("%s: Initiating discovery flow, unique_id=%s", log_identifier, unique_id)
discovery_flow.async_create_flow(
self.hass,
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=discovery_data,
)
@property
def status(self) -> DiscoveryStatus:
"""Get the discovery status"""
return self._status
def _is_user_configured(self, entity_id: str) -> bool:
"""Check if user have setup powercalc sensors for a given entity_id.
Either with the YAML or GUI method.
"""
if not self.manually_configured_entities:
self.manually_configured_entities = self._load_manually_configured_entities()
return entity_id in self.manually_configured_entities
def _load_manually_configured_entities(self) -> list[str]:
"""Looks at the YAML and GUI config entries for all the configured entity_id's."""
entities = []
# Find entity ids in yaml config (Legacy)
if SENSOR_DOMAIN in self.ha_config: # pragma: no cover
sensor_config = self.ha_config.get(SENSOR_DOMAIN)
platform_entries = [
item for item in sensor_config or {} if isinstance(item, dict) and item.get(CONF_PLATFORM) == DOMAIN
]
for entry in platform_entries:
entities.extend(self._find_entity_ids_in_yaml_config(entry))
# Find entity ids in yaml config (New)
domain_config: ConfigType = self.ha_config.get(DOMAIN, {})
if CONF_SENSORS in domain_config:
sensors = domain_config[CONF_SENSORS]
for sensor_config in sensors:
entities.extend(self._find_entity_ids_in_yaml_config(sensor_config))
# Add entities from existing config entries
entities.extend(
[
str(entry.data.get(CONF_ENTITY_ID))
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.source == SOURCE_USER
],
)
return entities
def _find_entity_ids_in_yaml_config(self, search_dict: dict) -> list[str]:
"""Takes a dict with nested lists and dicts,
and searches all dicts for a key of the field
provided.
"""
found_entity_ids: list[str] = []
self._extract_entity_ids(search_dict, found_entity_ids)
return found_entity_ids
def _extract_entity_ids(self, search_dict: dict, found_entity_ids: list[str]) -> None:
"""Helper function to recursively extract entity IDs."""
for key, value in search_dict.items():
if key == CONF_ENTITY_ID:
found_entity_ids.append(value)
elif isinstance(value, dict):
self._extract_entity_ids(value, found_entity_ids)
elif isinstance(value, list):
self._process_list_items(value, found_entity_ids)
def _process_list_items(self, items: list, found_entity_ids: list[str]) -> None:
"""Helper function to process list items."""
for item in items:
if isinstance(item, dict):
self._extract_entity_ids(item, found_entity_ids)
def _is_already_discovered(self, source_entity: SourceEntity, unique_id: str) -> bool:
"""Prevent duplicate discovery flows."""
unique_ids_to_check = [unique_id, source_entity.entity_id, source_entity.unique_id]
if unique_id.startswith("pc_"):
unique_ids_to_check.append(unique_id[3:])
unique_ids_to_check.extend([f"pc_{uid}" for uid in unique_ids_to_check])
return any(unique_id in self.initialized_flows for unique_id in unique_ids_to_check)
async def _get_library(self) -> ProfileLibrary:
"""Get the powercalc library instance."""
if not self.library:
self.library = await ProfileLibrary.factory(self.hass)
return self.library