from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Coroutine from copy import copy from datetime import datetime, timedelta from decimal import Decimal import logging from typing import Any, cast from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, MATCH_ALL, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfPower, ) from homeassistant.core import ( CALLBACK_TYPE, Event, HassJob, HomeAssistant, State, callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir, start from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import EntityCategory import homeassistant.helpers.entity_registry as er from homeassistant.helpers.event import ( EventStateChangedData, TrackTemplate, TrackTemplateResult, async_call_later, async_track_state_change_event, async_track_template_result, async_track_time_interval, ) from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, StateType from custom_components.powercalc.analytics.analytics import collect_analytics from custom_components.powercalc.common import SourceEntity from custom_components.powercalc.const import ( ATTR_CALCULATION_MODE, ATTR_ENERGY_SENSOR_ENTITY_ID, ATTR_INTEGRATION, ATTR_SOURCE_DOMAIN, ATTR_SOURCE_ENTITY, CALCULATION_STRATEGY_CONF_KEYS, CONF_AVAILABILITY_ENTITY, CONF_CALCULATION_ENABLED_CONDITION, CONF_CUSTOM_MODEL_DIRECTORY, CONF_DELAY, CONF_DISABLE_EXTENDED_ATTRIBUTES, CONF_DISABLE_STANDBY_POWER, CONF_IGNORE_UNAVAILABLE_STATE, CONF_MODEL, CONF_MULTIPLY_FACTOR, CONF_MULTIPLY_FACTOR_STANDBY, CONF_POWER, CONF_POWER_SENSOR_CATEGORY, CONF_POWER_SENSOR_ID, CONF_POWER_SENSOR_PRECISION, CONF_POWER_UPDATE_INTERVAL, CONF_SELF_USAGE_INCLUDED, CONF_SLEEP_POWER, CONF_STANDBY_POWER, CONF_UNAVAILABLE_POWER, DATA_POWER_PROFILE_SOURCES, DATA_POWER_PROFILES, DATA_STANDBY_POWER_SENSORS, DATA_STRATEGIES, DEFAULT_POWER_SENSOR_PRECISION, DOMAIN, DUMMY_ENTITY_ID, OFF_STATES, OFF_STATES_BY_DOMAIN, SIGNAL_POWER_SENSOR_STATE_CHANGE, UNAVAILABLE_STATES, CalculationStrategy, PowerProfileSource, ) from custom_components.powercalc.discovery import get_discovery_manager from custom_components.powercalc.errors import ( ModelNotSupportedError, StrategyConfigurationError, UnsupportedStrategyError, ) from custom_components.powercalc.power_profile.factory import get_power_profile from custom_components.powercalc.power_profile.power_profile import PowerProfile from custom_components.powercalc.power_profile.sub_profile_selector import SubProfileSelectConfig, SubProfileSelector from custom_components.powercalc.strategy.factory import PowerCalculatorStrategyFactory from custom_components.powercalc.strategy.playbook import PlaybookStrategy from custom_components.powercalc.strategy.selector import detect_calculation_strategy from custom_components.powercalc.strategy.strategy_interface import ( PowerCalculationStrategyInterface, ) from custom_components.powercalc.unit import evaluate_to_decimal from .abstract import ( BaseEntity, generate_power_sensor_entity_id, generate_power_sensor_name, ) _LOGGER = logging.getLogger(__name__) async def create_power_sensor( hass: HomeAssistant, sensor_config: ConfigType, source_entity: SourceEntity, config_entry: ConfigEntry | None, ) -> PowerSensor: """Create the power sensor based on powercalc sensor configuration.""" if CONF_POWER_SENSOR_ID in sensor_config: # Use an existing power sensor, only create energy sensors / utility meters return create_real_power_sensor(hass, sensor_config) return await create_virtual_power_sensor( hass, sensor_config, source_entity, config_entry, ) async def create_virtual_power_sensor( hass: HomeAssistant, sensor_config: ConfigType, source_entity: SourceEntity, config_entry: ConfigEntry | None, ) -> VirtualPowerSensor: """Create the power sensor entity.""" try: power_profile = await _get_power_profile(hass, sensor_config, source_entity) if power_profile: if power_profile.sensor_config != {}: sensor_config.update(power_profile.sensor_config) if CONF_CALCULATION_ENABLED_CONDITION not in sensor_config and power_profile.calculation_enabled_condition: sensor_config[CONF_CALCULATION_ENABLED_CONDITION] = power_profile.calculation_enabled_condition if ( config_entry and await power_profile.requires_manual_sub_profile_selection and "/" not in sensor_config.get(CONF_MODEL, "") ): ir.async_create_issue( hass, DOMAIN, f"sub_profile_{config_entry.entry_id}", is_fixable=True, severity=ir.IssueSeverity.WARNING, translation_key="sub_profile", translation_placeholders={"entry": config_entry.title}, data={"config_entry_id": config_entry.entry_id}, ) name = generate_power_sensor_name( sensor_config, sensor_config.get(CONF_NAME), source_entity, ) unique_id = sensor_config.get(CONF_UNIQUE_ID) or source_entity.unique_id entity_id = generate_power_sensor_entity_id( hass, sensor_config, source_entity, unique_id=unique_id, ) entity_category: str | None = sensor_config.get(CONF_POWER_SENSOR_CATEGORY) or None strategy = detect_calculation_strategy(sensor_config, power_profile) calculation_strategy_factory = PowerCalculatorStrategyFactory.get_instance(hass) standby_power, standby_power_on = _get_standby_power(hass, sensor_config, power_profile) # Collect runtime statistics, which we can publish daily a = collect_analytics(hass, config_entry) a.inc(DATA_STRATEGIES, strategy) a.add(DATA_POWER_PROFILES, power_profile) a.inc( DATA_POWER_PROFILE_SOURCES, power_profile.configuration_source if power_profile else PowerProfileSource.MANUAL, ) _LOGGER.debug( "Creating power sensor (entity_id=%s entity_category=%s, sensor_name=%s strategy=%s " "manufacturer=%s model=%s unique_id=%s)", source_entity.entity_id, entity_category, name, strategy, power_profile.manufacturer if power_profile else "", power_profile.model if power_profile else "", unique_id, ) power_sensor = VirtualPowerSensor( hass=hass, calculation_strategy_factory=calculation_strategy_factory, calculation_strategy=strategy, entity_id=entity_id, entity_category=entity_category, name=name, source_entity=source_entity, unique_id=unique_id, standby_power=standby_power, standby_power_on=standby_power_on, sensor_config=sensor_config, power_profile=power_profile, config_entry=config_entry, ) await power_sensor.validate() return power_sensor except (StrategyConfigurationError, UnsupportedStrategyError) as err: _LOGGER.error( "%s: Skipping sensor setup: %s", source_entity.entity_id, err, ) raise err async def _get_power_profile( hass: HomeAssistant, sensor_config: ConfigType, source_entity: SourceEntity, ) -> PowerProfile | None: """Retrieve the power profile based on auto-discovery or manual configuration.""" discovery_manager = get_discovery_manager(hass) if is_manually_configured(sensor_config): return None power_profile = None try: model_info = await discovery_manager.extract_model_info_from_device_info(source_entity.entity_entry) power_profile = await get_power_profile( hass, sensor_config, source_entity, model_info=model_info, ) if power_profile and power_profile.has_sub_profile_select_matchers: await _select_sub_profile(hass, power_profile, power_profile.sub_profile_select, source_entity) # type: ignore except ModelNotSupportedError as err: if not is_fully_configured(sensor_config): _LOGGER.error( "%s: Skipping sensor setup: %s", source_entity.entity_id, err, ) raise err return power_profile async def _select_sub_profile( hass: HomeAssistant, power_profile: PowerProfile, sub_profile: SubProfileSelectConfig, source_entity: SourceEntity, ) -> None: """Select the appropriate sub-profile based on the source entity's state.""" sub_profile_selector = SubProfileSelector( hass, sub_profile, source_entity, ) await power_profile.select_sub_profile( sub_profile_selector.select_sub_profile( State(source_entity.entity_id, STATE_UNKNOWN), ), ) def _resolve_standby_power_value( hass: HomeAssistant, value: Decimal | Template | str | float | None, ) -> Template | Decimal: if isinstance(value, Template): return value if isinstance(value, str) and value.startswith("{{"): return Template(value, hass) if value is None: return Decimal(0) if isinstance(value, Decimal): return value return Decimal(str(value)) def _get_standby_power( hass: HomeAssistant, sensor_config: ConfigType, power_profile: PowerProfile | None, ) -> tuple[Template | Decimal, Decimal]: """Retrieve standby power settings from sensor config or power profile.""" standby_power: Template | Decimal = Decimal(0) standby_power_on = Decimal(0) if sensor_config.get(CONF_SELF_USAGE_INCLUDED, False) or sensor_config.get(CONF_DISABLE_STANDBY_POWER): return standby_power, standby_power_on if sensor_config.get(CONF_STANDBY_POWER) is not None: standby_power = _resolve_standby_power_value( hass, sensor_config.get(CONF_STANDBY_POWER), ) elif power_profile is not None: standby_power = _resolve_standby_power_value( hass, power_profile.json_data.get(CONF_STANDBY_POWER), ) standby_power_on = Decimal(power_profile.standby_power_on) return standby_power, standby_power_on def create_real_power_sensor( hass: HomeAssistant, sensor_config: ConfigType, ) -> RealPowerSensor: """Create reference to an existing power sensor.""" power_sensor_id = sensor_config.get(CONF_POWER_SENSOR_ID) unique_id = sensor_config.get(CONF_UNIQUE_ID) device_id = None unit_of_measurement = None ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(power_sensor_id) # type: ignore if entity_entry: if not unique_id: unique_id = entity_entry.unique_id device_id = entity_entry.device_id unit_of_measurement = entity_entry.unit_of_measurement return RealPowerSensor( entity_id=power_sensor_id, # type: ignore device_id=device_id, unique_id=unique_id, unit_of_measurement=unit_of_measurement, ) def is_manually_configured(sensor_config: ConfigType) -> bool: """Check if the user manually configured the sensor. We need to skip loading a power profile to make. """ if CONF_CUSTOM_MODEL_DIRECTORY in sensor_config: return False if CONF_MODEL in sensor_config: return False return any(key in sensor_config for key in CALCULATION_STRATEGY_CONF_KEYS) def is_fully_configured(config: ConfigType) -> bool: return any(key in config for key in CALCULATION_STRATEGY_CONF_KEYS) class PowerSensor(BaseEntity): """Class which all power sensors should extend from.""" class VirtualPowerSensor(PowerSensor, SensorEntity): """Virtual power sensor.""" _attr_device_class = SensorDeviceClass.POWER _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = UnitOfPower.WATT _attr_should_poll: bool = False _unrecorded_attributes = frozenset({MATCH_ALL}) def __init__( self, hass: HomeAssistant, calculation_strategy_factory: PowerCalculatorStrategyFactory, calculation_strategy: CalculationStrategy, entity_id: str, entity_category: str | None, name: str, source_entity: SourceEntity, unique_id: str | None, standby_power: Decimal | Template, standby_power_on: Decimal, sensor_config: ConfigType, power_profile: PowerProfile | None, config_entry: ConfigEntry | None, ) -> None: """Initialize the sensor.""" self._calculation_strategy = calculation_strategy self._calculation_enabled_condition: Template | None = None self._source_entity = source_entity self._off_states: set[str] = OFF_STATES_BY_DOMAIN.get(source_entity.domain, set()) | OFF_STATES self._attr_name = name self._power: Decimal | None = None self._standby_power = standby_power self._standby_power_on = standby_power_on self._attr_force_update = True self._attr_unique_id = unique_id self._multiply_factor = sensor_config.get(CONF_MULTIPLY_FACTOR) self._multiply_factor_standby = bool(sensor_config.get(CONF_MULTIPLY_FACTOR_STANDBY, False)) self._ignore_unavailable_state = bool(sensor_config.get(CONF_IGNORE_UNAVAILABLE_STATE, False)) self._rounding_digits = int(sensor_config.get(CONF_POWER_SENSOR_PRECISION, DEFAULT_POWER_SENSOR_PRECISION)) self._attr_suggested_display_precision = self._rounding_digits self.entity_id = entity_id self._sensor_config = sensor_config self._track_entities: set[str] = set() self._sleep_power_timer: CALLBACK_TYPE | None = None if entity_category: self._attr_entity_category = EntityCategory(entity_category) if not sensor_config.get(CONF_DISABLE_EXTENDED_ATTRIBUTES): self._attr_extra_state_attributes = { ATTR_CALCULATION_MODE: calculation_strategy, ATTR_INTEGRATION: DOMAIN, ATTR_SOURCE_ENTITY: source_entity.entity_id, ATTR_SOURCE_DOMAIN: source_entity.domain, } self._power_profile = power_profile self._sub_profile_selector: SubProfileSelector | None = None if not self._ignore_unavailable_state and self._sensor_config.get(CONF_UNAVAILABLE_POWER) is not None: self._ignore_unavailable_state = True self._standby_sensors: ConfigType = hass.data[DOMAIN][DATA_STANDBY_POWER_SENSORS] self.calculation_strategy_factory = calculation_strategy_factory self._strategy_instance: PowerCalculationStrategyInterface | None = None self._availability_entity: str | None = sensor_config.get(CONF_AVAILABILITY_ENTITY) self._config_entry = config_entry async def validate(self) -> None: await self.ensure_strategy_instance() assert self._strategy_instance is not None await self._strategy_instance.validate_config() async def ensure_strategy_instance(self, recreate: bool = False) -> None: if self._strategy_instance is None or recreate: self._strategy_instance = await self.calculation_strategy_factory.create( self._sensor_config, self._calculation_strategy, self._power_profile, self._source_entity, ) async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() await self.ensure_strategy_instance() assert self._strategy_instance is not None self.init_calculation_enabled_condition() async def appliance_state_listener(event: Event[EventStateChangedData]) -> None: """Handle for state changes for dependent sensors.""" new_state = event.data.get("new_state") await self._handle_source_entity_state_change( self._source_entity.entity_id, new_state, ) async def template_change_listener(*_: object) -> None: """Handle for state changes for referenced templates.""" state = self.hass.states.get(self._source_entity.entity_id) await self._handle_source_entity_state_change( self._source_entity.entity_id, state, ) async def initial_update(hass: HomeAssistant) -> None: """Calculate initial value and push state""" # When using reload service the energy sensor became unavailable. # This is caused because the state change listener of the energy sensor is registered # before the power sensor pushes its initial update. Adding sleep 0 fixes this issue. await asyncio.sleep(0) if self._strategy_instance: await self._strategy_instance.on_start(hass) entities = self._track_entities if (not entities and self._source_entity.entity_id == DUMMY_ENTITY_ID) or not entities: entities.add(DUMMY_ENTITY_ID) for entity_id in entities: new_state = ( self.hass.states.get(entity_id) if entity_id != DUMMY_ENTITY_ID else State(entity_id, STATE_ON) ) await self._handle_source_entity_state_change( entity_id, new_state, ) # Add listeners for all tracking entities and templates. entities_to_track = self._get_tracking_entities() self._register_tracking_listeners(entities_to_track, appliance_state_listener, template_change_listener) # Trigger initial update self.async_on_remove(start.async_at_start(self.hass, initial_update)) self._strategy_instance.set_update_callback(self._update_power_sensor) self._register_force_update_interval() def _register_tracking_listeners( self, entities_to_track: list[str | TrackTemplate], appliance_state_listener: Callable[[Event[EventStateChangedData]], Awaitable[None]], template_change_listener: Callable[ [Event[EventStateChangedData] | None, list[TrackTemplateResult]], Coroutine[Any, Any, None] | None, ], ) -> None: self._track_entities = {e for e in entities_to_track if isinstance(e, str)} self.async_on_remove( async_track_state_change_event(self.hass, self._track_entities, appliance_state_listener), ) track_templates: list[TrackTemplate] = [e for e in entities_to_track if isinstance(e, TrackTemplate)] if track_templates: template_tracker = async_track_template_result( self.hass, track_templates=track_templates, action=template_change_listener, ) self.async_on_remove( template_tracker.async_remove, ) def _register_force_update_interval(self) -> None: force_update_interval = self._sensor_config.get(CONF_POWER_UPDATE_INTERVAL, 0) if force_update_interval <= 0: return @callback def async_update(__: datetime | None = None) -> None: self.async_schedule_update_ha_state(True) self.async_on_remove( async_track_time_interval( self.hass, async_update, timedelta(seconds=force_update_interval), cancel_on_shutdown=True, ), ) def _get_tracking_entities(self) -> list[str | TrackTemplate]: """Return entities and templates that should be tracked.""" entities_to_track = copy(self._strategy_instance.get_entities_to_track()) if self._strategy_instance else [] if self._power_profile and self._power_profile.has_sub_profile_select_matchers: self._sub_profile_selector = SubProfileSelector( self.hass, self._power_profile.sub_profile_select, # type: ignore self._source_entity, ) entities_to_track.extend(self._sub_profile_selector.get_tracking_entities()) if self._source_entity.entity_id != DUMMY_ENTITY_ID: entities_to_track.append(self._source_entity.entity_id) if self._availability_entity and self._availability_entity not in entities_to_track: entities_to_track.append(self._availability_entity) if isinstance(self._standby_power, Template): self._standby_power.hass = self.hass entities_to_track.append(TrackTemplate(self._standby_power, None, None)) if self._calculation_enabled_condition: entities_to_track.append(TrackTemplate(self._calculation_enabled_condition, None, None)) return entities_to_track def init_calculation_enabled_condition(self) -> None: """When a calculation enabled condition is configured, initialize the template.""" if CONF_CALCULATION_ENABLED_CONDITION not in self._sensor_config: return template: Template | str = self._sensor_config.get(CONF_CALCULATION_ENABLED_CONDITION) # type: ignore if isinstance(template, str): template = Template(template, self.hass) self._calculation_enabled_condition = template async def _handle_source_entity_state_change( self, trigger_entity_id: str, state: State | None, ) -> None: """Update power sensor based on new dependent entity state.""" self._standby_sensors.pop(self.entity_id, None) if self._sleep_power_timer: self._sleep_power_timer() self._sleep_power_timer = None if self.source_entity == DUMMY_ENTITY_ID and state is None: state = State(self.source_entity, STATE_UNKNOWN) if not state or not self._has_valid_state(state): _LOGGER.debug( "%s: Source entity has an invalid state, setting power sensor to unavailable", trigger_entity_id, ) self._update_power_and_write_state(None) return await self._switch_sub_profile_dynamically(state) power = await self.calculate_power(state) _LOGGER.debug( '%s: State changed to "%s". Power:%s', state.entity_id, state.state, self._power, ) self._update_power_and_write_state(power) async_dispatcher_send(self.hass, SIGNAL_POWER_SENSOR_STATE_CHANGE) def _update_power_and_write_state(self, power: Decimal | None) -> None: """Update the power sensor and write HA state.""" available = False if power is not None: power = round(power, self._rounding_digits) available = True if self._availability_entity: state = self.hass.states.get(self._availability_entity) available = bool(state and state.state != STATE_UNAVAILABLE) # Prevent writing the same state twice to the state machine if self._power == power and self.available == available: return self._power = power self._attr_available = available self.async_write_ha_state() @callback def _update_power_sensor(self, power: Decimal) -> None: """Update the power sensor with new power value from strategy and write HA state.""" if self._multiply_factor: power *= Decimal(self._multiply_factor) self._update_power_and_write_state(power) def _has_valid_state(self, state: State) -> bool: """Check if the state is valid, we can use it for power calculation.""" if self.source_entity == DUMMY_ENTITY_ID: return True return self._ignore_unavailable_state or state.state not in UNAVAILABLE_STATES async def calculate_power(self, state: State) -> Decimal | None: """Calculate power consumption using configured strategy.""" assert self._strategy_instance is not None entity_state = self._resolve_calculation_state(state) if entity_state is None: return None # Handle unavailable power unavailable_power = self._sensor_config.get(CONF_UNAVAILABLE_POWER) if entity_state.state == STATE_UNAVAILABLE and unavailable_power is not None: return Decimal(unavailable_power) standby_power = await self._calculate_state_standby_power(entity_state) if standby_power is not None and ( self._strategy_instance.can_calculate_standby() or self._calculation_strategy != CalculationStrategy.MULTI_SWITCH ): return standby_power # Calculate actual power using configured strategy power = await self._strategy_instance.calculate(entity_state) if power is None: return None return Decimal(self._apply_power_adjustments(power, standby_power)) def _resolve_calculation_state(self, state: State) -> State | None: if ( self._source_entity.entity_id == DUMMY_ENTITY_ID and self._calculation_strategy != CalculationStrategy.MULTI_SWITCH ): if self._availability_entity and state.entity_id == self._availability_entity: return State(DUMMY_ENTITY_ID, STATE_ON) return state if ( self._calculation_strategy == CalculationStrategy.MULTI_SWITCH or state.entity_id == self._source_entity.entity_id ): return state return cast(State | None, self.hass.states.get(self._source_entity.entity_id)) async def _calculate_state_standby_power(self, entity_state: State) -> Decimal | None: if entity_state.state not in self._off_states and await self.is_calculation_enabled(entity_state): return None assert self._strategy_instance is not None if isinstance(self._strategy_instance, PlaybookStrategy): await self._strategy_instance.stop_playbook() standby_power = await self.calculate_standby_power(entity_state) self._standby_sensors[self.entity_id] = standby_power return standby_power def _apply_power_adjustments(self, power: Decimal, standby_power: Decimal | None) -> Decimal: if standby_power: power += standby_power if self._multiply_factor: power *= Decimal(self._multiply_factor) if self._standby_power_on and not standby_power: additional_standby_power = self._standby_power_on self._standby_sensors[self.entity_id] = self._standby_power_on if self._multiply_factor_standby and self._multiply_factor: additional_standby_power *= Decimal(self._multiply_factor) power += additional_standby_power return power async def _switch_sub_profile_dynamically(self, state: State) -> None: """Dynamically select a different sub profile depending on the entity state or attributes Uses SubProfileSelect class which contains all the matching logic. """ if not self._power_profile or not self._power_profile.sub_profile_select or not self._sub_profile_selector: return new_profile = self._sub_profile_selector.select_sub_profile(state) await self._select_new_sub_profile(new_profile) async def _select_new_sub_profile(self, profile: str) -> None: """Selects a new sub profile on the power profile and updates standby power accordingly.""" if not self._power_profile or self._power_profile.sub_profile == profile: return await self._power_profile.select_sub_profile(profile) self._standby_power = _resolve_standby_power_value( self.hass, self._power_profile.json_data.get(CONF_STANDBY_POWER), ) self._standby_power_on = Decimal(self._power_profile.standby_power_on) await self.ensure_strategy_instance(True) async def calculate_standby_power(self, state: State) -> Decimal: """Calculate the power of the device in OFF state.""" assert self._strategy_instance is not None sleep_power: dict[str, float] = self._sensor_config.get(CONF_SLEEP_POWER) # type: ignore if sleep_power: delay = sleep_power.get(CONF_DELAY) or 0 @callback def _update_sleep_power(*_: object) -> None: power = Decimal(sleep_power.get(CONF_POWER) or 0) if self._multiply_factor_standby and self._multiply_factor: power *= Decimal(self._multiply_factor) self._update_power_and_write_state(power) self._sleep_power_timer = async_call_later( self.hass, delay, HassJob(_update_sleep_power, name=f"{self.entity_id} sleep power", cancel_on_shutdown=True), ) standby_power = self._standby_power if self._strategy_instance.can_calculate_standby(): standby_power = await self._strategy_instance.calculate(state) or self._standby_power evaluated = evaluate_to_decimal(standby_power) if evaluated is None: evaluated = Decimal(0) standby_power = evaluated if self._multiply_factor_standby and self._multiply_factor: standby_power *= Decimal(self._multiply_factor) return standby_power async def is_calculation_enabled(self, entity_state: State) -> bool: """Check if calculation is enabled based on the condition template.""" template = self._calculation_enabled_condition if not template: return self._strategy_instance.is_enabled(entity_state) # type: ignore return bool(template.async_render()) @property def source_entity(self) -> str: """The source entity this power sensor calculates power for.""" return self._source_entity.entity_id @property def native_value(self) -> StateType: """Return the state of the sensor.""" return cast(StateType, self._power) def set_energy_sensor_attribute(self, entity_id: str) -> None: """Set the energy sensor on the state attributes.""" if self._sensor_config.get(CONF_DISABLE_EXTENDED_ATTRIBUTES): return self._attr_extra_state_attributes.update( {ATTR_ENERGY_SENSOR_ENTITY_ID: entity_id}, ) async def async_activate_playbook(self, playbook_id: str) -> None: """Active a playbook""" strategy_instance = self._ensure_playbook_strategy() await strategy_instance.activate_playbook(playbook_id) async def async_stop_playbook(self) -> None: """Stop an active playbook""" strategy_instance = self._ensure_playbook_strategy() await strategy_instance.stop_playbook() def get_active_playbook(self) -> dict[str, str]: """Get the active playbook""" strategy_instance = self._ensure_playbook_strategy() playbook = strategy_instance.get_active_playbook() if not playbook: return {} return {"id": playbook.key} def _ensure_playbook_strategy(self) -> PlaybookStrategy: """Ensure we are dealing with a playbook sensor.""" assert self._strategy_instance is not None if not isinstance(self._strategy_instance, PlaybookStrategy): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="not_a_playbook_sensor", translation_placeholders={"entity_id": self.entity_id}, ) return self._strategy_instance async def async_will_remove_from_hass(self) -> None: """Cancel outstanding timers when the entity is removed.""" if self._sleep_power_timer is not None: self._sleep_power_timer() self._sleep_power_timer = None if isinstance(self._strategy_instance, PlaybookStrategy): await self._strategy_instance.stop_playbook() await super().async_will_remove_from_hass() async def async_switch_sub_profile(self, profile: str) -> None: """Switches to a new sub profile""" if ( not self._power_profile or not await self._power_profile.has_sub_profiles or self._power_profile.sub_profile_select ): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="no_sub_profile_support", translation_placeholders={"entity_id": self.entity_id}, ) known_profiles = [profile[0] for profile in await self._power_profile.get_sub_profiles()] if profile not in known_profiles: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="unknown_sub_profile", translation_placeholders={ "profile": profile, "known_profiles": ", ".join(known_profiles), }, ) await self._select_new_sub_profile(profile) await self._handle_source_entity_state_change( self._source_entity.entity_id, self.hass.states.get(self._source_entity.entity_id), ) # Persist the newly selected sub profile on the config entry if self._config_entry: new_model = f"{self._power_profile.model}/{profile}" self.hass.config_entries.async_update_entry( self._config_entry, data={**self._config_entry.data, CONF_MODEL: new_model}, ) class RealPowerSensor(PowerSensor): """Contains a reference to an existing real power sensor entity.""" def __init__( self, entity_id: str, unit_of_measurement: str | None = None, device_id: str | None = None, unique_id: str | None = None, ) -> None: self.entity_id = entity_id self._device_id = device_id self._unique_id = unique_id self._attr_unit_of_measurement = unit_of_measurement or UnitOfPower.WATT @property def device_id(self) -> str | None: """Return the device_id of the sensor.""" return self._device_id @property def unique_id(self) -> str | None: """Return the unique_id of the sensor.""" return self._unique_id