329 files

This commit is contained in:
Home Assistant Version Control
2026-08-06 13:56:25 +00:00
parent 0df89406fa
commit 7afe7add1d
330 changed files with 13098 additions and 5942 deletions
@@ -1,5 +1,3 @@
from __future__ import annotations
import logging
from typing import cast
@@ -19,9 +17,11 @@ from custom_components.powercalc.const import (
CONF_ENERGY_SENSOR_NAMING,
CONF_POWER_SENSOR_FRIENDLY_NAMING,
CONF_POWER_SENSOR_NAMING,
CONF_STANDBY_ENERGY_SENSOR_NAMING,
DEFAULT_COST_NAME_PATTERN,
DEFAULT_ENERGY_NAME_PATTERN,
DEFAULT_POWER_NAME_PATTERN,
DEFAULT_STANDBY_ENERGY_NAME_PATTERN,
DOMAIN,
)
from custom_components.powercalc.device_binding import bind_entity_to_registry_metadata
@@ -39,7 +39,7 @@ class BaseEntity(Entity):
bind_entity_to_registry_metadata(
self.hass,
self.entity_id,
cast(DeviceEntry | None, getattr(self, "device_entry", None)),
cast(DeviceEntry | None, getattr(self, "_powercalc_device_entry", None)),
cast(ConfigType | None, getattr(self, "_sensor_config", None)),
)
@@ -76,6 +76,22 @@ def generate_energy_sensor_name(
)
def generate_standby_energy_sensor_name(
sensor_config: ConfigType,
name: str | None = None,
source_entity: SourceEntity | None = None,
) -> str:
"""Generate the name to use for a standby energy sensor."""
return _generate_sensor_name(
sensor_config,
CONF_STANDBY_ENERGY_SENSOR_NAMING,
CONF_STANDBY_ENERGY_SENSOR_NAMING,
DEFAULT_STANDBY_ENERGY_NAME_PATTERN,
name,
source_entity,
)
def generate_cost_sensor_name(
sensor_config: ConfigType,
name: str | None = None,
@@ -152,6 +168,26 @@ def generate_energy_sensor_entity_id(
)
@callback
def generate_standby_energy_sensor_entity_id(
hass: HomeAssistant,
sensor_config: ConfigType,
source_entity: SourceEntity | None = None,
name: str | None = None,
unique_id: str | None = None,
) -> str:
"""Generate the entity ID to use for a standby energy sensor."""
return _generate_sensor_entity_id(
hass,
sensor_config,
CONF_STANDBY_ENERGY_SENSOR_NAMING,
DEFAULT_STANDBY_ENERGY_NAME_PATTERN,
source_entity,
name,
unique_id,
)
@callback
def generate_cost_sensor_entity_id(
hass: HomeAssistant,
+2 -8
View File
@@ -1,10 +1,8 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
import logging
from typing import TYPE_CHECKING
from homeassistant.components.sensor import ATTR_LAST_RESET, SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.const import (
@@ -40,11 +38,7 @@ from .abstract import (
generate_cost_sensor_name,
)
from .energy import EnergySensor, resolve_existing_energy_sensor
if TYPE_CHECKING:
from datetime import datetime
from .utility_meter import VirtualUtilityMeter
from .utility_meter import VirtualUtilityMeter
COST_ICON = "mdi:cash"
ATTR_LAST_ENERGY = "last_energy"
@@ -1,5 +1,3 @@
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, time, timedelta
from decimal import Decimal
+116 -4
View File
@@ -1,6 +1,4 @@
from __future__ import annotations
from datetime import timedelta
from datetime import datetime, timedelta
from decimal import Decimal
import inspect
import logging
@@ -48,8 +46,10 @@ from .abstract import (
BaseEntity,
generate_energy_sensor_entity_id,
generate_energy_sensor_name,
generate_standby_energy_sensor_entity_id,
generate_standby_energy_sensor_name,
)
from .power import PowerSensor, RealPowerSensor
from .power import PowerSensor, RealPowerSensor, VirtualPowerSensor
ENERGY_ICON = "mdi:lightning-bolt"
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
@@ -89,6 +89,46 @@ def create_energy_sensor(
return _create_virtual_energy_sensor(hass, sensor_config, power_sensor, source_entity)
def create_standby_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
power_sensor: VirtualPowerSensor,
source_entity: SourceEntity,
) -> VirtualStandbyEnergySensor:
"""Create an energy sensor which only integrates standby power."""
name = generate_standby_energy_sensor_name(sensor_config, sensor_config.get(CONF_NAME), source_entity)
unique_id = f"{power_sensor.unique_id}_standby_energy" if power_sensor.unique_id is not None else None
entity_id = generate_standby_energy_sensor_entity_id(
hass,
sensor_config,
source_entity,
unique_id=unique_id,
)
entity_category = sensor_config.get(CONF_ENERGY_SENSOR_CATEGORY)
unit_prefix = get_unit_prefix(hass, sensor_config, power_sensor)
_LOGGER.debug(
"Creating standby energy sensor (entity_id=%s, source_entity=%s, unit_prefix=%s)",
entity_id,
power_sensor.entity_id,
unit_prefix,
)
return VirtualStandbyEnergySensor(
hass=hass,
source_entity=power_sensor.entity_id,
unique_id=unique_id,
entity_id=entity_id,
entity_category=entity_category,
name=name,
unit_prefix=unit_prefix,
powercalc_source_entity=source_entity.entity_id,
powercalc_source_domain=source_entity.domain,
sensor_config=sensor_config,
power_sensor=power_sensor,
)
def resolve_existing_energy_sensor(hass: HomeAssistant, energy_sensor_id: str) -> RealEnergySensor:
"""Look up an existing energy sensor in the entity registry, raising when not found."""
entity_entry = er.async_get(hass).async_get(energy_sensor_id)
@@ -414,6 +454,78 @@ class VirtualEnergySensor(IntegrationSensor, EnergySensor):
self.async_write_ha_state()
class VirtualStandbyEnergySensor(VirtualEnergySensor):
"""Energy sensor integrating only the standby portion of a virtual power sensor."""
def __init__(
self,
hass: HomeAssistant,
source_entity: str,
entity_id: str,
sensor_config: ConfigType,
power_sensor: VirtualPowerSensor,
powercalc_source_entity: str | None = None,
powercalc_source_domain: str | None = None,
unique_id: str | None = None,
entity_category: EntityCategory | None = None,
name: str | None = None,
unit_prefix: str | None = None,
) -> None:
self._power_sensor = power_sensor
self._last_standby_power = power_sensor.current_standby_power
super().__init__(
hass=hass,
source_entity=source_entity,
entity_id=entity_id,
sensor_config=sensor_config,
powercalc_source_entity=powercalc_source_entity,
powercalc_source_domain=powercalc_source_domain,
unique_id=unique_id,
entity_category=entity_category,
name=name,
unit_prefix=unit_prefix,
)
async def async_added_to_hass(self) -> None:
"""Initialize standby tracking before registering integration callbacks."""
self._last_standby_power = self._power_sensor.current_standby_power
await super().async_added_to_hass()
def _integrate_on_state_change(
self,
old_timestamp: datetime | None,
new_timestamp: datetime | None,
old_state: State | None,
new_state: State | None,
) -> None:
"""Replace total power states with their standby component before integrating."""
current_standby_power = self._power_sensor.current_standby_power
old_state = self._state_with_power(old_state, self._last_standby_power)
new_state = self._state_with_power(new_state, current_standby_power)
self._last_standby_power = current_standby_power
super()._integrate_on_state_change(old_timestamp, new_timestamp, old_state, new_state)
def _schedule_max_sub_interval_exceeded_if_state_is_numeric(self, source_state: State | None) -> None:
"""Schedule periodic integration using standby power rather than total power."""
super()._schedule_max_sub_interval_exceeded_if_state_is_numeric(
self._state_with_power(source_state, self._power_sensor.current_standby_power),
)
@staticmethod
def _state_with_power(state: State | None, power: Decimal) -> State | None:
if state is None or state.state in UNAVAILABLE_STATES:
return state
return State(
state.entity_id,
str(power),
state.attributes,
last_changed=state.last_changed,
last_reported=state.last_reported,
last_updated=state.last_updated,
context=state.context,
)
class RealEnergySensor(EnergySensor):
"""Contains a reference to an existing energy sensor entity."""
@@ -1,5 +1,3 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
@@ -1,5 +1,3 @@
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable
from datetime import datetime, timedelta
@@ -113,7 +111,7 @@ from custom_components.powercalc.sensors.abstract import (
generate_power_sensor_entity_id,
generate_power_sensor_name,
)
from custom_components.powercalc.sensors.energy import EnergySensor, VirtualEnergySensor
from custom_components.powercalc.sensors.energy import EnergySensor, VirtualEnergySensor, VirtualStandbyEnergySensor
from custom_components.powercalc.sensors.energy_related import create_energy_related_sensors
from custom_components.powercalc.sensors.power import PowerSensor
from custom_components.powercalc.unit import (
@@ -235,6 +233,7 @@ def filter_entity_list_by_class(
filter_list = default_filters.copy() if default_filters else []
filter_list.append(lambda elm: not isinstance(elm, GroupedSensor))
filter_list.append(lambda elm: isinstance(elm, class_name))
filter_list.append(lambda elm: not isinstance(elm, VirtualStandbyEnergySensor))
return {
x.entity_id
for x in filter(
@@ -1,5 +1,3 @@
from __future__ import annotations
from decimal import Decimal
import logging
@@ -1,5 +1,3 @@
from __future__ import annotations
from decimal import Decimal
import logging
from typing import cast
@@ -1,5 +1,3 @@
from __future__ import annotations
from enum import StrEnum
import logging
+70 -58
View File
@@ -1,5 +1,3 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Coroutine
from copy import copy
@@ -298,30 +296,33 @@ def _resolve_standby_power_value(
return Decimal(str(value))
def _get_standby_power_from_profile(
hass: HomeAssistant,
power_profile: PowerProfile,
) -> tuple[Template | Decimal, Decimal]:
"""Return the standby power for the OFF and ON state as declared by a power profile."""
return (
_resolve_standby_power_value(hass, power_profile.json_data.get(CONF_STANDBY_POWER)),
Decimal(power_profile.standby_power_on),
)
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
return Decimal(0), Decimal(0)
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 _resolve_standby_power_value(hass, sensor_config.get(CONF_STANDBY_POWER)), Decimal(0)
return standby_power, standby_power_on
if power_profile is not None:
return _get_standby_power_from_profile(hass, power_profile)
return Decimal(0), Decimal(0)
def create_real_power_sensor(
@@ -404,7 +405,8 @@ class VirtualPowerSensor(PowerSensor, SensorEntity):
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)
multiply_factor = sensor_config.get(CONF_MULTIPLY_FACTOR)
self._multiply_factor: Decimal | None = Decimal(multiply_factor) if multiply_factor else None
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))
@@ -588,7 +590,7 @@ class VirtualPowerSensor(PowerSensor, SensorEntity):
state: State | None,
) -> None:
"""Update power sensor based on new dependent entity state."""
self._standby_sensors.pop(self.entity_id, None)
self._clear_standby_power()
if self._sleep_power_timer:
self._sleep_power_timer()
self._sleep_power_timer = None
@@ -640,9 +642,7 @@ class VirtualPowerSensor(PowerSensor, SensorEntity):
@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)
self._update_power_and_write_state(self._apply_multiply_factor(power))
def _has_valid_state(self, state: State) -> bool:
"""Check if the state is valid, we can use it for power calculation."""
@@ -664,6 +664,8 @@ class VirtualPowerSensor(PowerSensor, SensorEntity):
if entity_state.state == STATE_UNAVAILABLE and unavailable_power is not None:
return Decimal(unavailable_power)
# When the device is in standby the standby power is the total power, except for multi switch:
# the other switches may still be ON, so there the standby power is added to the calculated power.
standby_power = await self._calculate_state_standby_power(entity_state)
if standby_power is not None and (
self._strategy_instance.can_calculate_standby()
@@ -676,7 +678,7 @@ class VirtualPowerSensor(PowerSensor, SensorEntity):
if power is None:
return None
return Decimal(self._apply_power_adjustments(power, standby_power))
return self._apply_power_adjustments(power, standby_power)
def _resolve_calculation_state(self, state: State) -> State | None:
if (
@@ -704,25 +706,31 @@ class VirtualPowerSensor(PowerSensor, SensorEntity):
await self._strategy_instance.stop_playbook()
standby_power = await self.calculate_standby_power(entity_state)
self._standby_sensors[self.entity_id] = standby_power
self._track_standby_power(standby_power)
return standby_power
def _apply_power_adjustments(self, power: Decimal, standby_power: Decimal | None) -> Decimal:
"""Apply the multiply factor and add the standby power the device draws while ON."""
if standby_power:
power += standby_power
if self._multiply_factor:
power *= Decimal(self._multiply_factor)
power = self._apply_multiply_factor(power)
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
standby_power_on = self._apply_standby_multiply_factor(self._standby_power_on)
self._track_standby_power(standby_power_on)
power += standby_power_on
return power
def _apply_multiply_factor(self, power: Decimal) -> Decimal:
"""Apply the configured multiply factor to a power value."""
return power * self._multiply_factor if self._multiply_factor else power
def _apply_standby_multiply_factor(self, power: Decimal) -> Decimal:
"""Apply the multiply factor to a standby power value, only when enabled for standby."""
return self._apply_multiply_factor(power) if self._multiply_factor_standby else 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.
@@ -739,46 +747,50 @@ class VirtualPowerSensor(PowerSensor, SensorEntity):
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)
self._standby_power, self._standby_power_on = _get_standby_power_from_profile(self.hass, self._power_profile)
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),
)
self._schedule_sleep_power()
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
return self._apply_standby_multiply_factor(evaluate_to_decimal(standby_power) or Decimal(0))
if self._multiply_factor_standby and self._multiply_factor:
standby_power *= Decimal(self._multiply_factor)
def _schedule_sleep_power(self) -> None:
"""Switch the sensor over to the configured sleep power, after the device has been OFF for the delay."""
sleep_power: dict[str, float] | None = self._sensor_config.get(CONF_SLEEP_POWER)
if not sleep_power:
return
return standby_power
@callback
def _update_sleep_power(*_: object) -> None:
power = self._apply_standby_multiply_factor(Decimal(sleep_power.get(CONF_POWER) or 0))
self._track_standby_power(power)
self._update_power_and_write_state(power)
self._sleep_power_timer = async_call_later(
self.hass,
sleep_power.get(CONF_DELAY) or 0,
HassJob(_update_sleep_power, name=f"{self.entity_id} sleep power", cancel_on_shutdown=True),
)
@property
def current_standby_power(self) -> Decimal:
"""Return the standby portion of the current power value."""
return cast(Decimal, self._standby_sensors.get(self.entity_id, Decimal(0)))
def _track_standby_power(self, power: Decimal) -> None:
"""Record the standby portion of the current power, read by the standby group and energy sensors."""
self._standby_sensors[self.entity_id] = power
def _clear_standby_power(self) -> None:
"""Forget the standby portion, the device is no longer known to be in standby."""
self._standby_sensors.pop(self.entity_id, None)
async def is_calculation_enabled(self, entity_state: State) -> bool:
"""Check if calculation is enabled based on the condition template."""
@@ -1,5 +1,3 @@
from __future__ import annotations
from decimal import Decimal
import inspect
import logging