"""Platform for sensor integration.""" from __future__ import annotations from collections.abc import Mapping import copy from dataclasses import dataclass, field from datetime import timedelta import logging from typing import Any, cast import uuid from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, SensorEntity from homeassistant.components.utility_meter import max_28_days from homeassistant.components.utility_meter.const import METER_TYPES from homeassistant.components.utility_meter.sensor import UtilityMeterSensor from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ENTITIES, CONF_ENTITY_ID, CONF_ID, CONF_NAME, CONF_PATH, CONF_UNIQUE_ID, ) from homeassistant.core import Event, HomeAssistant, SupportsResponse, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.entity_registry as er from homeassistant.helpers.entity_registry import ( EVENT_ENTITY_REGISTRY_UPDATED, RegistryEntryDisabler, ) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import voluptuous as vol from . import DATA_GROUP_ENTITIES from .analytics.analytics import collect_analytics from .common import ( SourceEntity, create_source_entity, get_merged_sensor_configuration, validate_is_number, validate_name_pattern, ) from .const import ( CONF_AND, CONF_AVAILABILITY_ENTITY, CONF_CALCULATION_ENABLED_CONDITION, CONF_COMPOSITE, CONF_CREATE_ENERGY_SENSOR, CONF_CREATE_GROUP, CONF_CREATE_UTILITY_METERS, CONF_CUSTOM_MODEL_DIRECTORY, CONF_DAILY_FIXED_ENERGY, CONF_DELAY, CONF_DISABLE_STANDBY_POWER, CONF_ENERGY_FILTER_OUTLIER_ENABLED, CONF_ENERGY_FILTER_OUTLIER_MAX, CONF_ENERGY_INTEGRATION_METHOD, CONF_ENERGY_SENSOR_CATEGORY, CONF_ENERGY_SENSOR_ID, CONF_ENERGY_SENSOR_NAMING, CONF_ENERGY_SENSOR_UNIT_PREFIX, CONF_FILTER, CONF_FIXED, CONF_FORCE_CALCULATE_GROUP_ENERGY, CONF_FORCE_ENERGY_SENSOR_CREATION, CONF_GROUP_ENERGY_START_AT_ZERO, CONF_GROUP_TYPE, CONF_HIDE_MEMBERS, CONF_IGNORE_UNAVAILABLE_STATE, CONF_INCLUDE, CONF_INCLUDE_NON_POWERCALC_SENSORS, CONF_LINEAR, CONF_MANUFACTURER, CONF_MODE, CONF_MODEL, CONF_MULTI_SWITCH, CONF_MULTIPLY_FACTOR, CONF_MULTIPLY_FACTOR_STANDBY, CONF_NOT, CONF_ON_TIME, CONF_OR, CONF_PLAYBOOK, CONF_PLAYBOOKS, CONF_POWER, CONF_POWER_SENSOR_CATEGORY, CONF_POWER_SENSOR_ID, CONF_POWER_SENSOR_NAMING, CONF_POWER_TEMPLATE, CONF_SENSOR_TYPE, CONF_SLEEP_POWER, CONF_STANDBY_POWER, CONF_STATE, CONF_STATES_POWER, CONF_SUBTRACT_ENTITIES, CONF_UNAVAILABLE_POWER, CONF_UTILITY_METER_NET_CONSUMPTION, CONF_UTILITY_METER_OFFSET, CONF_UTILITY_METER_TARIFFS, CONF_UTILITY_METER_TYPES, CONF_VALUE, CONF_VALUE_TEMPLATE, CONF_VARIABLES, CONF_WLED, DATA_CONFIG_TYPES, DATA_CONFIGURED_ENTITIES, DATA_DOMAIN_ENTITIES, DATA_ENTITIES, DATA_ENTITY_TYPES, DATA_HAS_GROUP_INCLUDE, DATA_SENSOR_TYPES, DATA_SOURCE_DOMAINS, DATA_USED_UNIQUE_IDS, DISCOVERY_TYPE, DOMAIN, DOMAIN_CONFIG, DUMMY_ENTITY_ID, ENERGY_INTEGRATION_METHODS, ENTITY_CATEGORIES, ENTRY_DATA_ENERGY_ENTITY, ENTRY_DATA_POWER_ENTITY, ENTRY_GLOBAL_CONFIG_UNIQUE_ID, SERVICE_ACTIVATE_PLAYBOOK, SERVICE_CALIBRATE_ENERGY, SERVICE_CALIBRATE_UTILITY_METER, SERVICE_DEBUG_GROUP, SERVICE_GET_ACTIVE_PLAYBOOK, SERVICE_GET_GROUP_ENTITIES, SERVICE_INCREASE_DAILY_ENERGY, SERVICE_RESET_ENERGY, SERVICE_STOP_PLAYBOOK, SERVICE_SWITCH_SUB_PROFILE, CalculationStrategy, EntityType, GroupType, PowercalcDiscoveryType, SensorType, UnitPrefix, ) from .device_binding import attach_entities_to_source_device from .errors import ( PowercalcSetupError, SensorAlreadyConfiguredError, SensorConfigurationError, ) from .group_include.filter import FILTER_CONFIG, FilterOperator, create_composite_filter from .group_include.include import find_entities from .sensors.daily_energy import ( DAILY_FIXED_ENERGY_SCHEMA, create_daily_fixed_energy_power_sensor, create_daily_fixed_energy_sensor, ) from .sensors.energy import EnergySensor, create_energy_sensor from .sensors.group.config_entry_utils import add_to_associated_groups from .sensors.group.custom import GroupedSensor from .sensors.group.factory import create_group_sensors from .sensors.group.standby import StandbyPowerSensor from .sensors.power import PowerSensor, VirtualPowerSensor, create_power_sensor from .sensors.utility_meter import create_utility_meters from .strategy.composite import CONFIG_SCHEMA as COMPOSITE_SCHEMA from .strategy.fixed import CONFIG_SCHEMA as FIXED_SCHEMA from .strategy.linear import CONFIG_SCHEMA as LINEAR_SCHEMA from .strategy.multi_switch import CONFIG_SCHEMA as MULTI_SWITCH_SCHEMA from .strategy.playbook import CONFIG_SCHEMA as PLAYBOOK_SCHEMA from .strategy.wled import CONFIG_SCHEMA as WLED_SCHEMA _LOGGER = logging.getLogger(__name__) MAX_GROUP_NESTING_LEVEL = 5 SENSOR_CONFIG = { vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_AVAILABILITY_ENTITY): cv.entity_id, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_MODEL): cv.string, vol.Optional(CONF_MANUFACTURER): cv.string, vol.Optional(CONF_MODE): vol.In([cls.value for cls in CalculationStrategy]), vol.Optional(CONF_STANDBY_POWER): vol.Any(vol.Coerce(float), cv.template), vol.Optional(CONF_DISABLE_STANDBY_POWER): cv.boolean, vol.Optional(CONF_CUSTOM_MODEL_DIRECTORY): cv.string, vol.Optional(CONF_POWER_SENSOR_ID): cv.entity_id, vol.Optional(CONF_FORCE_ENERGY_SENSOR_CREATION): cv.boolean, vol.Optional(CONF_FORCE_CALCULATE_GROUP_ENERGY): cv.boolean, vol.Optional(CONF_FIXED): FIXED_SCHEMA, vol.Optional(CONF_LINEAR): LINEAR_SCHEMA, vol.Optional(CONF_MULTI_SWITCH): MULTI_SWITCH_SCHEMA, vol.Optional(CONF_WLED): WLED_SCHEMA, vol.Optional(CONF_PLAYBOOK): PLAYBOOK_SCHEMA, vol.Optional(CONF_DAILY_FIXED_ENERGY): DAILY_FIXED_ENERGY_SCHEMA, vol.Optional(CONF_CREATE_ENERGY_SENSOR): cv.boolean, vol.Optional(CONF_CREATE_UTILITY_METERS): cv.boolean, vol.Optional(CONF_UTILITY_METER_NET_CONSUMPTION): 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_MULTIPLY_FACTOR): vol.Coerce(float), vol.Optional(CONF_MULTIPLY_FACTOR_STANDBY): cv.boolean, vol.Optional(CONF_POWER_SENSOR_NAMING): validate_name_pattern, vol.Optional(CONF_POWER_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES), vol.Optional(CONF_ENERGY_SENSOR_ID): cv.entity_id, vol.Optional(CONF_ENERGY_SENSOR_NAMING): validate_name_pattern, vol.Optional(CONF_ENERGY_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES), vol.Optional(CONF_ENERGY_INTEGRATION_METHOD): vol.In(ENERGY_INTEGRATION_METHODS), vol.Optional(CONF_ENERGY_FILTER_OUTLIER_ENABLED): cv.boolean, vol.Optional(CONF_ENERGY_FILTER_OUTLIER_MAX): cv.positive_int, vol.Optional(CONF_ENERGY_SENSOR_UNIT_PREFIX): vol.In([cls.value for cls in UnitPrefix]), vol.Optional(CONF_CREATE_GROUP): cv.string, vol.Optional(CONF_GROUP_ENERGY_START_AT_ZERO): cv.boolean, vol.Optional(CONF_GROUP_TYPE): vol.In([cls.value for cls in GroupType]), vol.Optional(CONF_SUBTRACT_ENTITIES): vol.All(cv.ensure_list, [cv.entity_id]), vol.Optional(CONF_HIDE_MEMBERS): cv.boolean, vol.Optional(CONF_INCLUDE): vol.Schema( { **FILTER_CONFIG.schema, vol.Optional(CONF_FILTER): vol.Schema( { **FILTER_CONFIG.schema, vol.Optional(CONF_OR): vol.All(cv.ensure_list, [FILTER_CONFIG]), vol.Optional(CONF_AND): vol.All(cv.ensure_list, [FILTER_CONFIG]), vol.Optional(CONF_NOT): vol.All(cv.ensure_list, [FILTER_CONFIG]), }, ), vol.Optional(CONF_INCLUDE_NON_POWERCALC_SENSORS, default=True): cv.boolean, }, ), vol.Optional(CONF_IGNORE_UNAVAILABLE_STATE): cv.boolean, vol.Optional(CONF_CALCULATION_ENABLED_CONDITION): cv.template, vol.Optional(CONF_SLEEP_POWER): vol.Schema( { vol.Required(CONF_POWER): vol.Coerce(float), vol.Required(CONF_DELAY): cv.positive_int, }, ), vol.Optional(CONF_UNAVAILABLE_POWER): vol.Coerce(float), vol.Optional(CONF_COMPOSITE): COMPOSITE_SCHEMA, vol.Optional(CONF_VARIABLES): vol.Schema({cv.string: cv.string}), } def build_nested_configuration_schema(schema: dict, iteration: int = 0) -> dict: if iteration == MAX_GROUP_NESTING_LEVEL: return schema iteration += 1 schema.update( { vol.Optional(CONF_ENTITIES): vol.All( cv.ensure_list, [build_nested_configuration_schema(schema.copy(), iteration)], ), }, ) return schema SENSOR_CONFIG = build_nested_configuration_schema(SENSOR_CONFIG) PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key( CONF_ENTITY_ID, CONF_POWER_SENSOR_ID, CONF_ENTITIES, CONF_INCLUDE, CONF_DAILY_FIXED_ENERGY, ), PLATFORM_SCHEMA.extend(SENSOR_CONFIG), ) ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}" async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Setup sensors from YAML config sensor entries.""" # Legacy sensor platform config is used. Raise an issue. if not discovery_info and config: async_create_issue( hass, DOMAIN, "powercalc_deprecated_yaml", breaks_in_ha_version=None, is_fixable=False, severity=IssueSeverity.WARNING, learn_more_url="https://docs.powercalc.nl/configuration/migration/new-yaml-structure/", translation_key="deprecated_platform_yaml", translation_placeholders={"platform": SENSOR_DOMAIN}, ) if discovery_info: config = convert_discovery_info_to_sensor_config(discovery_info) if config.get(CONF_GROUP_TYPE) == GroupType.SUBTRACT: config[CONF_SENSOR_TYPE] = SensorType.GROUP if CONF_CREATE_GROUP in config: config[CONF_NAME] = config[CONF_CREATE_GROUP] await _async_setup_entities( hass, config, async_add_entities, is_yaml=True, discovery_type=discovery_info.get(DISCOVERY_TYPE) if discovery_info else None, ) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Setup sensors from config entry (GUI config flow).""" if entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID: return sensor_config = convert_config_entry_to_sensor_config(entry, hass) if CONF_UNIQUE_ID not in sensor_config: sensor_config[CONF_UNIQUE_ID] = entry.unique_id if CONF_ENTITY_ID in sensor_config: _register_entity_id_change_listener( hass, entry, str(sensor_config.get(CONF_ENTITY_ID)), ) await _async_setup_entities( hass, sensor_config, async_add_entities, config_entry=entry, ) # Add entry to an existing group await add_to_associated_groups(hass, entry) async def _async_setup_entities( hass: HomeAssistant, config: dict[str, Any], async_add_entities: AddEntitiesCallback, config_entry: ConfigEntry | None = None, is_yaml: bool = False, discovery_type: PowercalcDiscoveryType | None = None, ) -> None: """Main routine to setup power/energy sensors from provided configuration.""" register_entity_services() try: context = CreationContext( group=CONF_CREATE_GROUP in config, entity_config=config, is_yaml=is_yaml, discovery_type=discovery_type, ) entities = await create_sensors(hass, config, context, config_entry) if config_entry: save_entity_ids_on_config_entry(hass, config_entry, entities) except SensorConfigurationError as err: _LOGGER.error(err) return await attach_entities_to_source_device(config_entry, entities.new, hass, None) entities_to_add = [entity for entity in entities.new if isinstance(entity, SensorEntity)] for entity in entities_to_add: if isinstance(entity, GroupedSensor | StandbyPowerSensor): hass.data[DOMAIN][DATA_GROUP_ENTITIES][entity.entity_id] = entity else: hass.data[DOMAIN][DATA_ENTITIES][entity.entity_id] = entity collect_analytics(hass, config_entry).inc(DATA_ENTITY_TYPES, _resolve_entity_type(entity)) # See: https://github.com/bramstroker/homeassistant-powercalc/issues/1454 # Remove entities which are disabled because of a disabled device from the list of entities to add # When we add nevertheless the entity_platform code will set device_id to None and abort entity addition. # `async_added_to_hass` hook will not be called, which powercalc uses to bind the entity to device again # This causes the powercalc entity to never be bound to the device again and be disabled forever. entity_reg = er.async_get(hass) for entity in entities_to_add: existing_entry = entity_reg.async_get(entity.entity_id) if existing_entry and existing_entry.disabled_by == RegistryEntryDisabler.DEVICE: entities_to_add.remove(entity) async_add_entities(entities_to_add) def _register_entity_id_change_listener( hass: HomeAssistant, entry: ConfigEntry, source_entity_id: str, ) -> None: """ When the user changes the entity id of the source entity, we also need to change the powercalc config entry to reflect these changes This method adds the necessary listener and handler to facilitate this """ @callback def _entity_rename_listener(event: Event) -> None: """Handle renaming of the entity""" old_entity_id = event.data["old_entity_id"] new_entity_id = event.data[CONF_ENTITY_ID] _LOGGER.debug( "Entity id has been changed, updating powercalc config. old_id=%s, new_id=%s", old_entity_id, new_entity_id, ) hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_ENTITY_ID: new_entity_id}, ) @callback def _filter_entity_id(event: Mapping[str, Any] | Event) -> bool: """Only dispatch the listener for update events concerning the source entity""" # Breaking change in 2024.4.0, check for Event for versions prior to this event_data = event.data if isinstance(event, Event) else event return ( event_data["action"] == "update" and "old_entity_id" in event_data and event_data["old_entity_id"] == source_entity_id ) hass.bus.async_listen( EVENT_ENTITY_REGISTRY_UPDATED, _entity_rename_listener, event_filter=_filter_entity_id, ) def _resolve_entity_type(entity: Entity) -> EntityType: if isinstance(entity, UtilityMeterSensor): return EntityType.UTILITY_METER if isinstance(entity, EnergySensor): return EntityType.ENERGY_SENSOR if isinstance(entity, PowerSensor): return EntityType.POWER_SENSOR return EntityType.UNKNOWN # pragma: no cover @callback def save_entity_ids_on_config_entry( hass: HomeAssistant, config_entry: ConfigEntry, entities: EntitiesBucket, ) -> None: """Save the power and energy sensor entity_id's on the config entry We need this in group sensor logic to differentiate between energy sensor and utility meters. """ _LOGGER.debug("Saving entity ids on config entry %s", config_entry.entry_id) power_entities = [e.entity_id for e in entities.all() if isinstance(e, VirtualPowerSensor)] new_data = config_entry.data.copy() if power_entities: _LOGGER.debug("Setting power entity_id %s on config entry %s", power_entities[0], config_entry.entry_id) new_data.update({ENTRY_DATA_POWER_ENTITY: power_entities[0]}) if bool(config_entry.data.get(CONF_CREATE_ENERGY_SENSOR, True)): energy_entities = [e.entity_id for e in entities.all() if isinstance(e, EnergySensor)] if not energy_entities: raise SensorConfigurationError( # pragma: no cover f"No energy sensor created for config_entry {config_entry.entry_id}", ) new_data.update({ENTRY_DATA_ENERGY_ENTITY: energy_entities[0]}) _LOGGER.debug("Setting energy entity_id %s on config entry %s", energy_entities[0], config_entry.entry_id) elif ENTRY_DATA_ENERGY_ENTITY in new_data: _LOGGER.debug("Removing energy entity_id on config entry %s", config_entry.entry_id) new_data.pop(ENTRY_DATA_ENERGY_ENTITY) hass.config_entries.async_update_entry( config_entry, data=new_data, ) @callback def register_entity_services() -> None: """Register the different entity services.""" platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_RESET_ENERGY, {}, "async_reset", ) platform.async_register_entity_service( SERVICE_CALIBRATE_UTILITY_METER, {vol.Required(CONF_VALUE): validate_is_number}, "async_calibrate", ) platform.async_register_entity_service( SERVICE_CALIBRATE_ENERGY, {vol.Required(CONF_VALUE): validate_is_number}, "async_calibrate", ) platform.async_register_entity_service( SERVICE_INCREASE_DAILY_ENERGY, {vol.Required(CONF_VALUE): validate_is_number}, "async_increase", ) platform.async_register_entity_service( SERVICE_ACTIVATE_PLAYBOOK, {vol.Required("playbook_id"): cv.string}, "async_activate_playbook", ) platform.async_register_entity_service( SERVICE_STOP_PLAYBOOK, {}, "async_stop_playbook", ) platform.async_register_entity_service( SERVICE_GET_ACTIVE_PLAYBOOK, {}, "get_active_playbook", supports_response=SupportsResponse.ONLY, ) platform.async_register_entity_service( SERVICE_SWITCH_SUB_PROFILE, {vol.Required("profile"): cv.string}, "async_switch_sub_profile", ) platform.async_register_entity_service( SERVICE_GET_GROUP_ENTITIES, {}, "get_group_entities", supports_response=SupportsResponse.ONLY, ) platform.async_register_entity_service( SERVICE_DEBUG_GROUP, {}, "debug_group", supports_response=SupportsResponse.ONLY, ) def convert_config_entry_to_sensor_config(config_entry: ConfigEntry, hass: HomeAssistant) -> ConfigType: # noqa: C901 """Convert the config entry structure to the sensor config used to create the entities.""" sensor_config = dict(config_entry.data.copy()) sensor_type = sensor_config.get(CONF_SENSOR_TYPE) def handle_sensor_type() -> None: """Handle sensor type-specific configuration.""" if sensor_type == SensorType.GROUP: sensor_config[CONF_CREATE_GROUP] = sensor_config.get(CONF_NAME) elif sensor_type == SensorType.REAL_POWER: sensor_config[CONF_POWER_SENSOR_ID] = sensor_config.get(CONF_ENTITY_ID) sensor_config[CONF_FORCE_ENERGY_SENSOR_CREATION] = True def process_template(config: dict, template_key: str, target_key: str) -> None: """Convert a template key in the config to a Template object.""" if template_key in config: config[target_key] = Template(config[template_key], hass) del config[template_key] def process_on_time(config: dict) -> None: """Convert on_time dictionary to timedelta.""" on_time = config.get(CONF_ON_TIME) config[CONF_ON_TIME] = ( timedelta(hours=on_time["hours"], minutes=on_time["minutes"], seconds=on_time["seconds"]) if on_time else timedelta(days=1) ) def process_states_power(states_power: dict | list) -> dict: """Convert state power values to Template objects where necessary. Handles both dict format (legacy/YAML) and list format (config flow). """ if isinstance(states_power, list): states_power = {item[CONF_STATE]: item[CONF_POWER] for item in states_power} return { key: Template(value, hass) if isinstance(value, str) and "{{" in value else value for key, value in states_power.items() } def process_daily_fixed_energy() -> None: """Process daily fixed energy configuration.""" if CONF_DAILY_FIXED_ENERGY not in sensor_config: return daily_fixed_config = copy.copy(sensor_config[CONF_DAILY_FIXED_ENERGY]) process_template(daily_fixed_config, CONF_VALUE_TEMPLATE, CONF_VALUE) process_on_time(daily_fixed_config) sensor_config[CONF_DAILY_FIXED_ENERGY] = daily_fixed_config def process_fixed_config() -> None: """Process fixed energy configuration.""" if CONF_FIXED not in sensor_config: return fixed_config = copy.copy(sensor_config[CONF_FIXED]) process_template(fixed_config, CONF_POWER_TEMPLATE, CONF_POWER) if CONF_STATES_POWER in fixed_config: fixed_config[CONF_STATES_POWER] = process_states_power(fixed_config[CONF_STATES_POWER]) sensor_config[CONF_FIXED] = fixed_config def process_linear_config() -> None: """Process linear energy configuration.""" if CONF_LINEAR not in sensor_config: return linear_config = copy.copy(sensor_config[CONF_LINEAR]) sensor_config[CONF_LINEAR] = linear_config def process_calculation_enabled_condition() -> None: """Process calculation enabled condition template.""" if CONF_CALCULATION_ENABLED_CONDITION in sensor_config: sensor_config[CONF_CALCULATION_ENABLED_CONDITION] = Template( sensor_config[CONF_CALCULATION_ENABLED_CONDITION], hass, ) def process_utility_meter_offset() -> None: if CONF_UTILITY_METER_OFFSET in sensor_config: sensor_config[CONF_UTILITY_METER_OFFSET] = timedelta(days=sensor_config[CONF_UTILITY_METER_OFFSET]) def process_playbook_config() -> None: if CONF_PLAYBOOK not in sensor_config: return playbook_config = copy.copy(sensor_config[CONF_PLAYBOOK]) playbook_config[CONF_PLAYBOOKS] = {item[CONF_ID]: item[CONF_PATH] for item in playbook_config[CONF_PLAYBOOKS]} sensor_config[CONF_PLAYBOOK] = playbook_config handle_sensor_type() process_daily_fixed_energy() process_fixed_config() process_linear_config() process_playbook_config() process_calculation_enabled_condition() process_utility_meter_offset() return sensor_config def convert_discovery_info_to_sensor_config( discovery_info: DiscoveryInfoType, ) -> ConfigType: """Convert discovery info to sensor config.""" if discovery_info[DISCOVERY_TYPE] == PowercalcDiscoveryType.DOMAIN_GROUP: config = discovery_info config[CONF_GROUP_TYPE] = GroupType.DOMAIN config[CONF_SENSOR_TYPE] = SensorType.GROUP config[CONF_ENTITY_ID] = DUMMY_ENTITY_ID return config if discovery_info[DISCOVERY_TYPE] == PowercalcDiscoveryType.STANDBY_GROUP: config = discovery_info config[CONF_GROUP_TYPE] = GroupType.STANDBY config[CONF_SENSOR_TYPE] = SensorType.GROUP config[CONF_ENTITY_ID] = DUMMY_ENTITY_ID return config return discovery_info async def create_sensors( hass: HomeAssistant, config: ConfigType, context: CreationContext, config_entry: ConfigEntry | None = None, ) -> EntitiesBucket: """Main routine to create all sensors (power, energy, utility, group) for a given entity.""" global_config = hass.data[DOMAIN][DOMAIN_CONFIG] if is_individual_sensor_setup(config): return await setup_individual_sensors(hass, config, global_config, config_entry, context) sensor_configs: dict[str, ConfigType] = {} entities_to_add = EntitiesBucket() await setup_nested_or_group_sensors(hass, config, context, entities_to_add, sensor_configs) await add_discovered_entities(hass, config, entities_to_add, sensor_configs) await create_entities_sensors(hass, config, global_config, context, config_entry, sensor_configs, entities_to_add) if not entities_to_add.has_entities(): log_missing_entities_warning(config) return entities_to_add await create_group_if_needed(hass, config, global_config, entities_to_add) return entities_to_add def is_individual_sensor_setup(config: ConfigType) -> bool: """Check if the setup is for an individual sensor.""" return CONF_ENTITIES not in config and CONF_INCLUDE not in config async def setup_individual_sensors( hass: HomeAssistant, config: ConfigType, global_config: ConfigType, config_entry: ConfigEntry | None, context: CreationContext, ) -> EntitiesBucket: """Set up an individual sensor.""" merged_sensor_config = get_merged_sensor_configuration(global_config, config) sensor_type = SensorType(str(config.get(CONF_SENSOR_TYPE, SensorType.VIRTUAL_POWER))) if sensor_type == SensorType.GROUP: collect_sensor_analytics(hass, sensor_type, context.discovery_type, config_entry) return EntitiesBucket(new=await create_group_sensors(hass, merged_sensor_config, config_entry)) return await create_individual_sensors(hass, merged_sensor_config, context, sensor_type, config_entry) def collect_sensor_analytics( hass: HomeAssistant, sensor_type: SensorType, discovery_type: PowercalcDiscoveryType | None, config_entry: ConfigEntry | None = None, ) -> None: """Collect sensor analytics data.""" a = collect_analytics(hass, config_entry) a.inc(DATA_SENSOR_TYPES, sensor_type) if discovery_type and discovery_type == PowercalcDiscoveryType.USER_YAML: a.inc(DATA_CONFIG_TYPES, "yaml") if config_entry: a.inc(DATA_CONFIG_TYPES, "gui") async def setup_nested_or_group_sensors( hass: HomeAssistant, config: ConfigType, context: CreationContext, entities_to_add: EntitiesBucket, sensor_configs: dict, ) -> None: """Set up sensors for nested or grouped entities.""" for entity_config in config.get(CONF_ENTITIES, []): if CONF_ENTITIES in entity_config or context.group: await handle_nested_entity(hass, entity_config, context, entities_to_add) else: entity_id = entity_config.get(CONF_ENTITY_ID) or str(uuid.uuid4()) sensor_configs[entity_id] = entity_config async def handle_nested_entity( hass: HomeAssistant, entity_config: ConfigType, context: CreationContext, entities_to_add: EntitiesBucket, ) -> None: """Handle nested entities recursively.""" try: child_entities = await create_sensors( hass, entity_config, context=CreationContext( group=context.group, entity_config=entity_config, is_yaml=context.is_yaml, discovery_type=context.discovery_type, ), ) entities_to_add.extend_items(child_entities) except SensorConfigurationError as exception: _LOGGER.error( "Group state might be misbehaving because there was an error with an entity", exc_info=exception, ) async def add_discovered_entities( hass: HomeAssistant, config: ConfigType, entities_to_add: EntitiesBucket, sensor_configs: dict, ) -> None: """Add discovered entities based on include configuration.""" if CONF_INCLUDE in config: collect_analytics(hass).set_flag(DATA_HAS_GROUP_INCLUDE) include_config: dict = cast(dict, config[CONF_INCLUDE]) include_non_powercalc: bool = include_config.get(CONF_INCLUDE_NON_POWERCALC_SENSORS, True) entity_filter = create_composite_filter(include_config, hass, FilterOperator.AND) found_entities = await find_entities(hass, entity_filter, include_non_powercalc) entities_to_add.existing.extend(found_entities.resolved) for entity_id in found_entities.discoverable: sensor_configs[entity_id] = {CONF_ENTITY_ID: entity_id} async def create_entities_sensors( hass: HomeAssistant, config: ConfigType, global_config: ConfigType, context: CreationContext, config_entry: ConfigEntry | None, sensor_configs: dict, entities_to_add: EntitiesBucket, ) -> None: """Create sensors for each entity.""" for sensor_config in sensor_configs.values(): try: merged_sensor_config = get_merged_sensor_configuration( global_config, config, sensor_config, ) entities_to_add.extend_items( await create_individual_sensors( hass, merged_sensor_config, config_entry=config_entry, sensor_type=merged_sensor_config.get(CONF_SENSOR_TYPE, SensorType.VIRTUAL_POWER), context=CreationContext( group=context.group, entity_config=sensor_config, is_yaml=context.is_yaml, discovery_type=context.discovery_type, ), ), ) except SensorConfigurationError as error: _LOGGER.error(error) def log_missing_entities_warning(config: ConfigType) -> None: """Log a warning if no entities could be resolved.""" log_message = "Could not resolve any entities" if CONF_CREATE_GROUP in config: log_message += f" in group '{config.get(CONF_CREATE_GROUP)}'" _LOGGER.warning(log_message) async def create_group_if_needed( hass: HomeAssistant, config: ConfigType, global_config: ConfigType, entities_to_add: EntitiesBucket, ) -> None: """Create group sensors if required by the configuration.""" if CONF_CREATE_GROUP not in config: return collect_sensor_analytics(hass, SensorType.GROUP, PowercalcDiscoveryType.USER_YAML) entities_to_add.new.extend( await create_group_sensors( hass, get_merged_sensor_configuration(global_config, config, validate=False), None, entities_to_add.all(), ), ) async def create_individual_sensors( hass: HomeAssistant, sensor_config: dict, context: CreationContext, sensor_type: SensorType, config_entry: ConfigEntry | None = None, ) -> EntitiesBucket: """Create entities (power, energy, utility meters) which track the appliance.""" source_entity = create_source_entity(sensor_config[CONF_ENTITY_ID], hass) # For device-based profiles, attach the device entry to the source entity source_entity = _attach_configured_device_entry(hass, sensor_config, source_entity) used_unique_ids = hass.data[DOMAIN].get(DATA_USED_UNIQUE_IDS, []) try: check_entity_not_already_configured( sensor_config, source_entity, hass, used_unique_ids, context, ) except SensorAlreadyConfiguredError as error: # Include previously discovered/configured entities in group when no specific configuration if context.group and list(context.entity_config.keys()) == [CONF_ENTITY_ID]: return EntitiesBucket([], error.get_existing_entities()) raise error collect_sensor_analytics(hass, sensor_type, context.discovery_type, config_entry) entities_to_add: list[Entity] = [] energy_sensor = await handle_energy_sensor_creation(hass, sensor_config, source_entity, entities_to_add) if not energy_sensor: try: power_sensor = await create_power_sensor(hass, sensor_config, source_entity, config_entry) except PowercalcSetupError: return EntitiesBucket() entities_to_add.append(power_sensor) energy_sensor = create_energy_sensor_if_needed(hass, sensor_config, power_sensor, source_entity) if energy_sensor: entities_to_add.append(energy_sensor) attach_energy_sensor_to_power_sensor(power_sensor, energy_sensor) if energy_sensor: entities_to_add.extend(create_utility_meters(hass, energy_sensor, sensor_config, config_entry)) await attach_entities_to_source_device(config_entry, entities_to_add, hass, source_entity) update_registries(hass, source_entity, entities_to_add, context) unique_id = sensor_config.get(CONF_UNIQUE_ID) or source_entity.unique_id if unique_id: used_unique_ids.append(unique_id) collect_analytics(hass, config_entry).inc(DATA_SOURCE_DOMAINS, source_entity.domain) return EntitiesBucket(new=entities_to_add, existing=[]) def _attach_configured_device_entry( hass: HomeAssistant, sensor_config: dict, source_entity: SourceEntity, ) -> SourceEntity: if source_entity.entity_id != DUMMY_ENTITY_ID or "device" not in sensor_config: return source_entity device_registry = dr.async_get(hass) device_entry = device_registry.async_get(sensor_config["device"]) if device_entry: return source_entity._replace(device_entry=device_entry) return source_entity async def handle_energy_sensor_creation( hass: HomeAssistant, sensor_config: dict, source_entity: SourceEntity, entities_to_add: list[Entity], ) -> EnergySensor | None: """Handle the creation of an energy sensor if needed.""" if CONF_DAILY_FIXED_ENERGY in sensor_config: energy_sensor = create_daily_fixed_energy_sensor(hass, sensor_config, source_entity) entities_to_add.append(energy_sensor) if source_entity: daily_fixed_power_sensor = await create_daily_fixed_energy_power_sensor(hass, sensor_config, source_entity) if daily_fixed_power_sensor: entities_to_add.append(daily_fixed_power_sensor) return energy_sensor return None def create_energy_sensor_if_needed( hass: HomeAssistant, sensor_config: dict, power_sensor: PowerSensor, source_entity: SourceEntity, ) -> EnergySensor | None: """Create an energy sensor if it is needed.""" if ( sensor_config.get(CONF_CREATE_ENERGY_SENSOR) or sensor_config.get(CONF_FORCE_ENERGY_SENSOR_CREATION) or CONF_ENERGY_SENSOR_ID in sensor_config ): return create_energy_sensor(hass, sensor_config, power_sensor, source_entity) return None def attach_energy_sensor_to_power_sensor(power_sensor: Entity, energy_sensor: EnergySensor) -> None: """Attach the energy sensor to the power sensor.""" if isinstance(power_sensor, VirtualPowerSensor): power_sensor.set_energy_sensor_attribute(energy_sensor.entity_id) def update_registries( hass: HomeAssistant, source_entity: SourceEntity, entities_to_add: list[Entity], creation_context: CreationContext, ) -> None: """Update various registries with the new entities.""" hass.data[DOMAIN][DATA_CONFIGURED_ENTITIES].update( {source_entity.entity_id: [(entity, creation_context.is_yaml) for entity in entities_to_add]}, ) domain_entities = hass.data[DOMAIN][DATA_DOMAIN_ENTITIES].setdefault(source_entity.domain, []) domain_entities.extend(entities_to_add) def check_entity_not_already_configured( sensor_config: dict, source_entity: SourceEntity, hass: HomeAssistant, used_unique_ids: list[str], context: CreationContext, ) -> None: if source_entity.entity_id == DUMMY_ENTITY_ID: return entity_id = source_entity.entity_id configured_entities: dict[str, list[tuple[Entity, bool]]] = hass.data[DOMAIN][DATA_CONFIGURED_ENTITIES] entities = configured_entities.get(entity_id, []) existing_entities = [e for e, _ in entities] unique_id = sensor_config.get(CONF_UNIQUE_ID) or source_entity.unique_id # Prevent duplicate unique_id within this creation run if unique_id in used_unique_ids: raise SensorAlreadyConfiguredError(entity_id, existing_entities) # UI flow cannot add when a YAML-defined entity already exists if not context.is_yaml and any(is_yaml for _, is_yaml in entities): raise SensorAlreadyConfiguredError(entity_id, existing_entities) # YAML flow without unique_id cannot add when any entity already exists if context.is_yaml and not sensor_config.get(CONF_UNIQUE_ID) and existing_entities: raise SensorAlreadyConfiguredError(entity_id, existing_entities) @dataclass class EntitiesBucket: new: list[Entity] = field(default_factory=list) existing: list[Entity] = field(default_factory=list) def extend_items(self, bucket: EntitiesBucket) -> None: """Append current entity bucket with new one""" self.new.extend(bucket.new) self.existing.extend(bucket.existing) def all(self) -> list[Entity]: """Return all entities both new and existing""" return self.new + self.existing def has_entities(self) -> bool: """Check whether the entity bucket is not empty""" return bool(self.new) or bool(self.existing) @dataclass class CreationContext: group: bool = field(default=False) entity_config: ConfigType = field(default_factory=dict) is_yaml: bool = field(default=False) discovery_type: PowercalcDiscoveryType | None = field(default=None)