Files
HomeAssistantVS/custom_components/powercalc/__init__.py
T

604 lines
23 KiB
Python

"""The PowerCalc integration."""
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_DEVICE,
CONF_DOMAIN,
CONF_ENABLED,
EVENT_HOMEASSISTANT_STARTED,
Platform,
__version__ as HA_VERSION, # noqa: N812
)
from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
from homeassistant.helpers import issue_registry as ir
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,
ISSUE_COMPOSITE_DEVICE_ID,
MIN_HA_VERSION,
SERVICE_CHANGE_GUI_CONFIGURATION,
SERVICE_RELOAD,
SERVICE_UPDATE_LIBRARY,
PowercalcDiscoveryType,
SensorType,
UnitPrefix,
)
from .device_binding import is_composite_device_id
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[ConfigType] = 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)
_async_handle_composite_device_issue(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."""
ir.async_delete_issue(hass, DOMAIN, _composite_device_issue_id(config_entry.entry_id))
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)
def _composite_device_issue_id(config_entry_id: str) -> str:
"""Return the repair issue ID for a config entry using a composite device ID."""
return f"{ISSUE_COMPOSITE_DEVICE_ID}_{config_entry_id}"
def _async_handle_composite_device_issue(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Report ambiguous legacy device IDs."""
issue_id = _composite_device_issue_id(entry.entry_id)
device_id = entry.data.get(CONF_DEVICE)
if not device_id or not is_composite_device_id(hass, str(device_id)):
ir.async_delete_issue(hass, DOMAIN, issue_id)
return
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
data={"config_entry_id": entry.entry_id},
is_fixable=True,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_COMPOSITE_DEVICE_ID,
translation_placeholders={"name": entry.title},
)
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}",
},
),
)