Initil after Upgrade

This commit is contained in:
2026-06-15 10:53:52 -04:00
parent 2fe9bf0dd6
commit 887feaa50a
143 changed files with 2288 additions and 881 deletions
+123 -54
View File
@@ -1,6 +1,7 @@
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
@@ -25,6 +26,7 @@ from homeassistant.const import (
from homeassistant.core import (
CALLBACK_TYPE,
Event,
HassJob,
HomeAssistant,
State,
callback,
@@ -37,6 +39,7 @@ 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,
@@ -122,7 +125,7 @@ async def create_power_sensor(
"""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 await create_real_power_sensor(hass, sensor_config)
return create_real_power_sensor(hass, sensor_config)
return await create_virtual_power_sensor(
hass,
@@ -147,7 +150,11 @@ async def create_virtual_power_sensor(
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, ""):
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,
@@ -181,10 +188,14 @@ async def create_virtual_power_sensor(
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)
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)",
"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,
@@ -313,7 +324,7 @@ def _get_standby_power(
return standby_power, standby_power_on
async def create_real_power_sensor(
def create_real_power_sensor(
hass: HomeAssistant,
sensor_config: dict,
) -> RealPowerSensor:
@@ -450,7 +461,7 @@ class VirtualPowerSensor(SensorEntity, PowerSensor):
new_state,
)
async def template_change_listener(*_: Any) -> None: # noqa: ANN401
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(
@@ -461,9 +472,9 @@ class VirtualPowerSensor(SensorEntity, PowerSensor):
async def initial_update(hass: HomeAssistant) -> None:
"""Calculate initial value and push state"""
# When using reload service energy sensor became unavailable
# This is caused because state change listener of energy sensor is registered before power sensor pushes initial update
# Adding sleep 0 fixes this issue.
# 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)
@@ -472,7 +483,9 @@ class VirtualPowerSensor(SensorEntity, PowerSensor):
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)
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,
@@ -480,30 +493,56 @@ class VirtualPowerSensor(SensorEntity, PowerSensor):
# Add listeners for all tracking entities and templates.
entities_to_track = self._get_tracking_entities()
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:
async_track_template_result(self.hass, track_templates=track_templates, action=template_change_listener)
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))
if hasattr(self._strategy_instance, "set_update_callback"):
self._strategy_instance.set_update_callback(self._update_power_sensor)
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:
if force_update_interval <= 0:
return
@callback
def async_update(__: datetime | None = None) -> None:
self.async_schedule_update_ha_state(True)
@callback
def async_update(__: datetime | None = None) -> None:
self.async_schedule_update_ha_state(True)
async_track_time_interval(self.hass, async_update, timedelta(seconds=force_update_interval))
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."""
@@ -616,16 +655,8 @@ class VirtualPowerSensor(SensorEntity, PowerSensor):
"""Calculate power consumption using configured strategy."""
assert self._strategy_instance is not None
# Resolve the relevant entity state
entity_state = state
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:
entity_state = State(DUMMY_ENTITY_ID, STATE_ON)
elif (
self._calculation_strategy != CalculationStrategy.MULTI_SWITCH
and state.entity_id != self._source_entity.entity_id
and (entity_state := self.hass.states.get(self._source_entity.entity_id)) is None
):
entity_state = self._resolve_calculation_state(state)
if entity_state is None:
return None
# Handle unavailable power
@@ -633,31 +664,56 @@ class VirtualPowerSensor(SensorEntity, PowerSensor):
if entity_state.state == STATE_UNAVAILABLE and unavailable_power is not None:
return Decimal(unavailable_power)
# Handle standby power
standby_power = None
if entity_state.state in self._off_states or not await self.is_calculation_enabled(entity_state):
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
if self._strategy_instance.can_calculate_standby() or self._calculation_strategy != CalculationStrategy.MULTI_SWITCH:
return standby_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
# Add standby power if available
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
# Apply multiply factor to power
if self._multiply_factor:
power *= Decimal(self._multiply_factor)
# Add standby power-on adjustments if applicable
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
@@ -665,7 +721,7 @@ class VirtualPowerSensor(SensorEntity, PowerSensor):
additional_standby_power *= Decimal(self._multiply_factor)
power += additional_standby_power
return Decimal(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
@@ -698,7 +754,7 @@ class VirtualPowerSensor(SensorEntity, PowerSensor):
delay = sleep_power.get(CONF_DELAY) or 0
@callback
def _update_sleep_power(*_: Any) -> None: # noqa: ANN401
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)
@@ -707,14 +763,14 @@ class VirtualPowerSensor(SensorEntity, PowerSensor):
self._sleep_power_timer = async_call_later(
self.hass,
delay,
_update_sleep_power,
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 = await evaluate_power(standby_power)
evaluated = evaluate_power(standby_power)
if evaluated is None:
evaluated = Decimal(0)
standby_power = evaluated
@@ -775,9 +831,22 @@ class VirtualPowerSensor(SensorEntity, PowerSensor):
raise HomeAssistantError("supported only playbook enabled sensors")
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:
if (
not self._power_profile
or not await self._power_profile.has_sub_profiles
or self._power_profile.sub_profile_select
):
raise HomeAssistantError(
"This is only supported for sensors having sub profiles, and no automatic profile selection",
)