from __future__ import annotations from collections.abc import Awaitable, Callable from datetime import timedelta from enum import StrEnum import logging import re from typing import Any 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 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__) 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._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(_: Any) -> None: # noqa: ANN401 """Rediscover entities.""" await self.update_library_and_rediscover() async_track_time_interval( self.hass, _rediscover, timedelta(hours=2), ) 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.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) # type: ignore[arg-type] _LOGGER.debug("Start device discovery") await self.perform_discovery(self.get_devices, self.create_device_source, DiscoveryBy.DEVICE) # type: ignore[arg-type] _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 = await 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]], source_creator: Callable[[er.RegistryEntry | dr.DeviceEntry], Awaitable[SourceEntity]], discovery_type: DiscoveryBy, ) -> None: """Generalized discovery procedure for entities and devices.""" for source in await source_provider(): log_identifier = source.entity_id if discovery_type == DiscoveryBy.ENTITY else source.id 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 await 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 and not profile.is_entity_domain_supported( source_entity.entity_entry, # type: ignore[arg-type] ): 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 await get_filtered_entity_list(self.hass, NotFilter(entity_filter)) async def get_devices(self) -> list: """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.""" 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