911 lines
35 KiB
Python
911 lines
35 KiB
Python
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
|