Files
Home-Assistant/custom_components/powercalc/__init__.py
T
2026-07-20 22:52:35 -04:00

574 lines
22 KiB
Python

"""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 .configuration.sensor_config import SENSOR_CONFIG
from .const import (
CONF_COST_SENSOR_FRIENDLY_NAMING,
CONF_COST_SENSOR_NAMING,
CONF_CREATE_COST_SENSORS,
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_PRICE,
CONF_ENERGY_PRICE_MULTIPLIER,
CONF_ENERGY_PRICE_SENSOR,
CONF_ENERGY_PRICE_SURCHARGE,
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, get_discovery_manager
from .migrate import async_fix_legacy_profile_config_entry, async_migrate_config_entry
from .power_profile.power_profile import DeviceType
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_COST_SENSORS): cv.boolean,
vol.Optional(CONF_ENERGY_PRICE): vol.Coerce(float),
vol.Optional(CONF_ENERGY_PRICE_SENSOR): cv.entity_id,
vol.Optional(CONF_ENERGY_PRICE_SURCHARGE): vol.Coerce(float),
vol.Optional(CONF_ENERGY_PRICE_MULTIPLIER): vol.Coerce(float),
vol.Optional(CONF_COST_SENSOR_NAMING): validate_name_pattern,
vol.Optional(CONF_COST_SENSOR_FRIENDLY_NAMING): validate_name_pattern,
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 = get_discovery_manager(hass)
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_yaml_sensor(hass, 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_yaml_sensor(hass, 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_yaml_sensor(hass, sensor_config, config))
for sensor_config in primary_sensors
),
)
async def _async_load_yaml_sensor(hass: HomeAssistant, sensor_config: ConfigType, config: ConfigType) -> None:
await async_load_platform(hass, Platform.SENSOR, DOMAIN, sensor_config, config)
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])
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 = get_discovery_manager(hass)
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 = get_discovery_manager(hass)
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}",
},
),
)