"""The PowerCalc integration.""" from __future__ import annotations import asyncio from functools import partial import logging import random import time from typing import Any from awesomeversion.awesomeversion import AwesomeVersion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.utility_meter import max_28_days from homeassistant.components.utility_meter.const import METER_TYPES from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_DOMAIN, CONF_ENABLED, EVENT_HOMEASSISTANT_STARTED, Platform, __version__ as HA_VERSION, # noqa: N812 ) from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.entity_platform import async_get_platforms import homeassistant.helpers.entity_registry as er from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType import voluptuous as vol from .analytics.analytics import ANALYTICS_INTERVAL, Analytics from .common import validate_name_pattern from .configuration.global_config import ( FLAG_HAS_GLOBAL_GUI_CONFIG, get_global_configuration, ) from .const import ( CONF_CREATE_DOMAIN_GROUPS, CONF_CREATE_ENERGY_SENSORS, CONF_CREATE_STANDBY_GROUP, CONF_CREATE_UTILITY_METERS, CONF_DISABLE_EXTENDED_ATTRIBUTES, CONF_DISABLE_LIBRARY_DOWNLOAD, CONF_DISCOVERY, CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED, CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED, CONF_ENABLE_ANALYTICS, CONF_ENABLE_AUTODISCOVERY_DEPRECATED, CONF_ENERGY_INTEGRATION_METHOD, CONF_ENERGY_SENSOR_CATEGORY, CONF_ENERGY_SENSOR_FRIENDLY_NAMING, CONF_ENERGY_SENSOR_NAMING, CONF_ENERGY_SENSOR_PRECISION, CONF_ENERGY_SENSOR_UNIT_PREFIX, CONF_ENERGY_UPDATE_INTERVAL, CONF_EXCLUDE_DEVICE_TYPES, CONF_EXCLUDE_SELF_USAGE, CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED, CONF_GROUP_ENERGY_UPDATE_INTERVAL, CONF_GROUP_POWER_UPDATE_INTERVAL, CONF_GROUP_UPDATE_INTERVAL_DEPRECATED, CONF_IGNORE_UNAVAILABLE_STATE, CONF_INCLUDE, CONF_INCLUDE_NON_POWERCALC_SENSORS, CONF_POWER_SENSOR_CATEGORY, CONF_POWER_SENSOR_FRIENDLY_NAMING, CONF_POWER_SENSOR_NAMING, CONF_POWER_SENSOR_PRECISION, CONF_POWER_UPDATE_INTERVAL, CONF_SENSOR_TYPE, CONF_SENSORS, CONF_UNAVAILABLE_POWER, CONF_UTILITY_METER_OFFSET, CONF_UTILITY_METER_TARIFFS, CONF_UTILITY_METER_TYPES, DATA_ANALYTICS, DATA_CONFIGURED_ENTITIES, DATA_DISCOVERY_MANAGER, DATA_DOMAIN_ENTITIES, DATA_ENTITIES, DATA_GROUP_ENTITIES, DATA_STANDBY_POWER_SENSORS, DATA_USED_UNIQUE_IDS, DISCOVERY_TYPE, DOMAIN, DOMAIN_CONFIG, ENERGY_INTEGRATION_METHODS, ENTITY_CATEGORIES, ENTRY_GLOBAL_CONFIG_UNIQUE_ID, MIN_HA_VERSION, SERVICE_CHANGE_GUI_CONFIGURATION, SERVICE_RELOAD, SERVICE_UPDATE_LIBRARY, PowercalcDiscoveryType, SensorType, UnitPrefix, ) from .discovery import DiscoveryManager, DiscoveryStatus from .migrate import async_fix_legacy_profile_config_entry, async_migrate_config_entry from .power_profile.power_profile import DeviceType from .sensor import SENSOR_CONFIG from .sensors.group.config_entry_utils import ( get_entries_excluding_global_config, get_entries_having_subgroup, remove_power_sensor_from_associated_groups, ) from .service.gui_configuration import SERVICE_SCHEMA, change_gui_configuration PLATFORMS = [Platform.SENSOR, Platform.SELECT] DISCOVERY_SCHEMA = vol.Schema( { vol.Optional(CONF_ENABLED): cv.boolean, vol.Optional(CONF_EXCLUDE_DEVICE_TYPES): vol.All( cv.ensure_list, [cls.value for cls in DeviceType], ), vol.Optional(CONF_EXCLUDE_SELF_USAGE): cv.boolean, }, ) CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default=dict): vol.All( cv.deprecated(CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED), cv.deprecated(CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED), cv.deprecated(CONF_ENABLE_AUTODISCOVERY_DEPRECATED), cv.deprecated(CONF_GROUP_UPDATE_INTERVAL_DEPRECATED), cv.deprecated(CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED), vol.Schema( { vol.Optional(CONF_ENABLE_ANALYTICS): cv.boolean, vol.Optional( CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED, ): cv.time_period, vol.Optional(CONF_GROUP_UPDATE_INTERVAL_DEPRECATED): cv.positive_int, vol.Optional(CONF_GROUP_POWER_UPDATE_INTERVAL): cv.positive_int, vol.Optional(CONF_GROUP_ENERGY_UPDATE_INTERVAL): cv.positive_int, vol.Optional(CONF_ENERGY_UPDATE_INTERVAL): cv.positive_int, vol.Optional(CONF_POWER_UPDATE_INTERVAL): cv.positive_int, vol.Optional(CONF_POWER_SENSOR_NAMING): validate_name_pattern, vol.Optional(CONF_POWER_SENSOR_FRIENDLY_NAMING): validate_name_pattern, vol.Optional(CONF_POWER_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES), vol.Optional(CONF_ENERGY_SENSOR_NAMING): validate_name_pattern, vol.Optional(CONF_ENERGY_SENSOR_FRIENDLY_NAMING): validate_name_pattern, vol.Optional(CONF_ENERGY_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES), vol.Optional(CONF_DISABLE_EXTENDED_ATTRIBUTES): cv.boolean, vol.Optional(CONF_DISABLE_LIBRARY_DOWNLOAD): cv.boolean, vol.Optional(CONF_DISCOVERY): DISCOVERY_SCHEMA, vol.Optional(CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED): vol.All( cv.ensure_list, [cls.value for cls in DeviceType], ), vol.Optional(CONF_ENABLE_AUTODISCOVERY_DEPRECATED): cv.boolean, vol.Optional(CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED): cv.boolean, vol.Optional(CONF_CREATE_ENERGY_SENSORS): cv.boolean, vol.Optional(CONF_CREATE_UTILITY_METERS): cv.boolean, vol.Optional(CONF_UTILITY_METER_TARIFFS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_UTILITY_METER_TYPES): vol.All(cv.ensure_list, [vol.In(METER_TYPES)]), vol.Optional( CONF_UTILITY_METER_OFFSET, ): vol.All(cv.time_period, cv.positive_timedelta, max_28_days), vol.Optional(CONF_ENERGY_INTEGRATION_METHOD): vol.In(ENERGY_INTEGRATION_METHODS), vol.Optional(CONF_ENERGY_SENSOR_PRECISION): cv.positive_int, vol.Optional(CONF_POWER_SENSOR_PRECISION): cv.positive_int, vol.Optional(CONF_ENERGY_SENSOR_UNIT_PREFIX): vol.In([cls.value for cls in UnitPrefix]), vol.Optional(CONF_CREATE_DOMAIN_GROUPS): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_IGNORE_UNAVAILABLE_STATE): cv.boolean, vol.Optional(CONF_UNAVAILABLE_POWER): vol.Coerce(float), vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_CONFIG]), vol.Optional(CONF_INCLUDE_NON_POWERCALC_SENSORS): cv.boolean, vol.Optional(CONF_CREATE_STANDBY_GROUP): cv.boolean, }, ), ), }, extra=vol.ALLOW_EXTRA, ) _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if AwesomeVersion(HA_VERSION) < AwesomeVersion(MIN_HA_VERSION): # pragma: no cover msg = ( "This integration requires at least HomeAssistant version " f" {MIN_HA_VERSION}, you are running version {HA_VERSION}." " Please upgrade HomeAssistant to continue use this integration." ) _notify_message(hass, "inv_ha_version", "PowerCalc", msg) _LOGGER.critical(msg) return False global_config = get_global_configuration(hass, config) discovery_manager = await create_discovery_manager_instance(hass, config, global_config) hass.data[DOMAIN] = { DATA_DISCOVERY_MANAGER: discovery_manager, DOMAIN_CONFIG: global_config, DATA_CONFIGURED_ENTITIES: {}, DATA_DOMAIN_ENTITIES: {}, DATA_GROUP_ENTITIES: {}, DATA_ENTITIES: {}, DATA_USED_UNIQUE_IDS: [], DATA_STANDBY_POWER_SENSORS: {}, DATA_ANALYTICS: {}, } register_services(hass) await async_load_platform(hass, Platform.SELECT, DOMAIN, {}, config) await setup_yaml_sensors(hass, config, global_config) setup_domain_groups(hass, global_config) setup_standby_group(hass, global_config) try: await repair_none_config_entries_issue(hass) except Exception as e: # pragma: no cover _LOGGER.error("problem while cleaning up None entities", exc_info=e) # pragma: no cover await init_analytics(hass) return True async def init_analytics(hass: HomeAssistant) -> None: """Initialize the Analytics manager and schedule daily submission""" analytics = Analytics(hass) await analytics.load() @callback def start_schedule(_event: Event) -> None: """Start the send schedule after the started event.""" async_call_later( hass, 600, HassJob( analytics.send_analytics, name="powercalc analytics startup", cancel_on_shutdown=True, ), ) async_track_time_interval( hass, analytics.send_analytics, ANALYTICS_INTERVAL, name="powercalc analytics daily", cancel_on_shutdown=True, ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) async def create_discovery_manager_instance( hass: HomeAssistant, ha_config: ConfigType, global_powercalc_config: ConfigType, ) -> DiscoveryManager: discovery_config = global_powercalc_config.get(CONF_DISCOVERY, {}) exclude_device_types = [ DeviceType(device_type) for device_type in discovery_config.get(CONF_EXCLUDE_DEVICE_TYPES, []) ] exclude_self_usage = discovery_config.get(CONF_EXCLUDE_SELF_USAGE, False) enable_autodiscovery = discovery_config.get(CONF_ENABLED, True) manager = DiscoveryManager( hass, ha_config, exclude_device_types=exclude_device_types, exclude_self_usage_profiles=exclude_self_usage, enabled=enable_autodiscovery, ) await manager.setup() return manager def register_services(hass: HomeAssistant) -> None: """Register generic services""" async def _handle_change_gui_service(call: ServiceCall) -> None: await change_gui_configuration(hass, call) hass.services.async_register( DOMAIN, SERVICE_CHANGE_GUI_CONFIGURATION, _handle_change_gui_service, schema=SERVICE_SCHEMA, ) async def _handle_update_library_service(_: ServiceCall) -> None: _LOGGER.info("Updating library and rediscovering devices") discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER] await discovery_manager.update_library_and_rediscover() hass.services.async_register( DOMAIN, SERVICE_UPDATE_LIBRARY, _handle_update_library_service, ) async def _reload_config(_: ServiceCall) -> None: """Reload powercalc.""" reload_config = await async_integration_yaml_config(hass, DOMAIN) reset_platforms = async_get_platforms(hass, DOMAIN) for reset_platform in reset_platforms: await reset_platform.async_reset() if not reload_config: return # pragma: nocover hass.data[DOMAIN][DATA_USED_UNIQUE_IDS] = [] hass.data[DOMAIN][DATA_CONFIGURED_ENTITIES] = {} hass.data[DOMAIN][DATA_ANALYTICS] = {} hass.data[DOMAIN][DOMAIN_CONFIG] = get_global_configuration(hass, reload_config) # Reload YAML sensors if any if DOMAIN in reload_config: for sensor_config in reload_config[DOMAIN].get(CONF_SENSORS, []): sensor_config.update({DISCOVERY_TYPE: PowercalcDiscoveryType.USER_YAML}) await async_load_platform( hass, Platform.SENSOR, DOMAIN, sensor_config, reload_config, ) # Reload all config entries for entry in hass.config_entries.async_entries(DOMAIN): _LOGGER.debug("Reloading config entry %s", entry.entry_id) await hass.config_entries.async_reload(entry.entry_id) global_config = get_global_configuration(hass, reload_config) setup_domain_groups(hass, global_config) await create_standby_group(hass, global_config) hass.services.async_register( DOMAIN, SERVICE_RELOAD, _reload_config, ) async def create_standby_group( hass: HomeAssistant, domain_config: ConfigType, event: Event[Any] | None = None, ) -> None: if not bool(domain_config.get(CONF_CREATE_STANDBY_GROUP, True)): return hass.async_create_task( async_load_platform( hass, SENSOR_DOMAIN, DOMAIN, {DISCOVERY_TYPE: PowercalcDiscoveryType.STANDBY_GROUP}, domain_config, ), ) def setup_standby_group(hass: HomeAssistant, domain_config: ConfigType) -> None: hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, partial(create_standby_group, hass, domain_config), ) def setup_domain_groups(hass: HomeAssistant, global_config: ConfigType) -> None: domain_groups: list[str] | None = global_config.get(CONF_CREATE_DOMAIN_GROUPS) if not domain_groups: return _LOGGER.debug("Setting up domain based group sensors..") for domain in domain_groups: hass.async_create_task( async_load_platform( hass, SENSOR_DOMAIN, DOMAIN, { DISCOVERY_TYPE: PowercalcDiscoveryType.DOMAIN_GROUP, CONF_DOMAIN: domain, }, global_config, ), ) async def setup_yaml_sensors( hass: HomeAssistant, config: ConfigType, domain_config: ConfigType, ) -> None: sensors: list = domain_config.get(CONF_SENSORS, []) primary_sensors = [] secondary_sensors = [] for sensor_config in sensors: sensor_config.update({DISCOVERY_TYPE: PowercalcDiscoveryType.USER_YAML}) if CONF_INCLUDE in sensor_config: secondary_sensors.append(sensor_config) else: primary_sensors.append(sensor_config) async def _load_secondary_sensors(_: None) -> None: """Load secondary sensors after primary sensors.""" await asyncio.gather( *( hass.async_create_task( async_load_platform( hass, Platform.SENSOR, DOMAIN, sensor_config, config, ), ) for sensor_config in secondary_sensors ), ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _load_secondary_sensors) await asyncio.gather( *( hass.async_create_task( async_load_platform( hass, Platform.SENSOR, DOMAIN, sensor_config, config, ), ) for sensor_config in primary_sensors ), ) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Powercalc integration from a config entry.""" await async_fix_legacy_profile_config_entry(hass, entry) await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR, Platform.SELECT]) # await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) entry.async_on_unload(entry.add_update_listener(async_update_entry)) # Check if this is the initial creation of the global configuration entry # If so, update the global configuration with the GUI configuration # When the flag is set, the global configuration has already been applied during async_setup if entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID: global_config = hass.data[DOMAIN][DOMAIN_CONFIG] if global_config.get(FLAG_HAS_GLOBAL_GUI_CONFIG, False) is False: await apply_global_gui_configuration_changes(hass) discovery_enabled = bool(entry.data.get(CONF_DISCOVERY, {}).get(CONF_ENABLED, False)) discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER] if discovery_enabled and discovery_manager.status == DiscoveryStatus.DISABLED: _LOGGER.debug("Enabling discovery manager based on global configuration") discovery_manager.enable() await discovery_manager.setup() if not discovery_enabled and discovery_manager.status != DiscoveryStatus.DISABLED: _LOGGER.debug("Disabling discovery manager based on global configuration") await discovery_manager.disable() return True async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update a given config entry.""" if entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID: await apply_global_gui_configuration_changes(hass) await hass.config_entries.async_reload(entry.entry_id) # Also reload all "parent" groups referring this group as a subgroup for related_entry in get_entries_having_subgroup(hass, entry): await hass.config_entries.async_reload(related_entry.entry_id) async def apply_global_gui_configuration_changes(hass: HomeAssistant) -> None: """Apply global configuration changes to all entities.""" global_config = hass.data[DOMAIN][DOMAIN_CONFIG] global_config.clear() global_config.update(get_global_configuration(hass, {})) for entry in get_entries_excluding_global_config(hass): if entry.state != ConfigEntryState.LOADED: # pragma: no cover continue await hass.config_entries.async_reload(entry.entry_id) async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS, ) if unload_ok: used_unique_ids: list[str] = hass.data[DOMAIN][DATA_USED_UNIQUE_IDS] try: if config_entry.unique_id: used_unique_ids.remove(config_entry.unique_id) except ValueError: return True return unload_ok async def async_remove_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Called after a config entry is removed.""" discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER] discovery_manager.remove_initialized_flow(config_entry) updated_entries: list[ConfigEntry] = [] sensor_type = config_entry.data.get(CONF_SENSOR_TYPE) if sensor_type == SensorType.VIRTUAL_POWER: updated_entries = remove_power_sensor_from_associated_groups( hass, config_entry, ) for entry in updated_entries: if entry.state == ConfigEntryState.LOADED: await hass.config_entries.async_reload(entry.entry_id) async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" await async_migrate_config_entry(hass, config_entry) return True async def repair_none_config_entries_issue(hass: HomeAssistant) -> None: """Repair issue with config entries having None as data.""" entity_registry = er.async_get(hass) entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.title == "None"] for entry in entries: _LOGGER.debug("Removing entry %s with None data", entry.entry_id) entities = entity_registry.entities.get_entries_for_config_entry_id(entry.entry_id) for entity in entities: entity_registry.async_remove(entity.entity_id) try: unique_id = f"{int(time.time() * 1000)}_{random.randint(1000, 9999)}" # noqa: S311 object.__setattr__(entry, "unique_id", unique_id) hass.config_entries._entries._index_entry(entry) # noqa: SLF001 await hass.config_entries.async_remove(entry.entry_id) except Exception as e: # pragma: no cover _LOGGER.error("problem while cleaning up None entities", exc_info=e) # pragma: no cover def _notify_message( hass: HomeAssistant, notification_id: str, title: str, message: str, ) -> None: # pragma: no cover """Notify user with persistent notification.""" hass.async_create_task( hass.services.async_call( domain="persistent_notification", service="create", service_data={ "title": title, "message": message, "notification_id": f"{DOMAIN}.{notification_id}", }, ), )