Initial Commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
__version__ as HA_VERSION, # noqa
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
from homeassistant.helpers.entity import Entity, async_generate_entity_id
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_ENERGY_SENSOR_FRIENDLY_NAMING,
|
||||
CONF_ENERGY_SENSOR_NAMING,
|
||||
CONF_POWER_SENSOR_FRIENDLY_NAMING,
|
||||
CONF_POWER_SENSOR_NAMING,
|
||||
DEFAULT_ENERGY_NAME_PATTERN,
|
||||
DEFAULT_POWER_NAME_PATTERN,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseEntity(Entity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Attach the entity to same device as the source entity."""
|
||||
|
||||
entity_reg = er.async_get(self.hass)
|
||||
entity_entry = entity_reg.async_get(self.entity_id)
|
||||
if entity_entry is None or not hasattr(self, "source_device_id"):
|
||||
return
|
||||
|
||||
device_id: str = getattr(self, "source_device_id") # noqa: B009
|
||||
device_reg = dr.async_get(self.hass)
|
||||
device_entry = device_reg.async_get(device_id)
|
||||
if not device_entry or device_entry.id == entity_entry.device_id: # pragma: no cover
|
||||
return
|
||||
_LOGGER.debug("Binding %s to device %s", self.entity_id, device_id)
|
||||
entity_reg.async_update_entity(self.entity_id, device_id=device_id)
|
||||
|
||||
|
||||
def generate_power_sensor_name(
|
||||
sensor_config: ConfigType,
|
||||
name: str | None = None,
|
||||
source_entity: SourceEntity | None = None,
|
||||
) -> str:
|
||||
"""Generates the name to use for a power sensor."""
|
||||
return _generate_sensor_name(
|
||||
sensor_config,
|
||||
CONF_POWER_SENSOR_NAMING,
|
||||
CONF_POWER_SENSOR_FRIENDLY_NAMING,
|
||||
name,
|
||||
source_entity,
|
||||
)
|
||||
|
||||
|
||||
def generate_energy_sensor_name(
|
||||
sensor_config: ConfigType,
|
||||
name: str | None = None,
|
||||
source_entity: SourceEntity | None = None,
|
||||
) -> str:
|
||||
"""Generates the name to use for an energy sensor."""
|
||||
return _generate_sensor_name(
|
||||
sensor_config,
|
||||
CONF_ENERGY_SENSOR_NAMING,
|
||||
CONF_ENERGY_SENSOR_FRIENDLY_NAMING,
|
||||
name,
|
||||
source_entity,
|
||||
)
|
||||
|
||||
|
||||
def _generate_sensor_name(
|
||||
sensor_config: ConfigType,
|
||||
naming_conf_key: str,
|
||||
friendly_naming_conf_key: str,
|
||||
name: str | None = None,
|
||||
source_entity: SourceEntity | None = None,
|
||||
) -> str:
|
||||
"""Generates the name to use for a sensor."""
|
||||
if name is None and source_entity:
|
||||
name = source_entity.name
|
||||
|
||||
if friendly_naming_conf_key in sensor_config:
|
||||
friendly_name_pattern = str(sensor_config.get(friendly_naming_conf_key))
|
||||
return friendly_name_pattern.format(name)
|
||||
|
||||
name_pattern = str(
|
||||
sensor_config.get(
|
||||
naming_conf_key,
|
||||
DEFAULT_POWER_NAME_PATTERN if naming_conf_key == CONF_POWER_SENSOR_NAMING else DEFAULT_ENERGY_NAME_PATTERN,
|
||||
),
|
||||
)
|
||||
return name_pattern.format(name)
|
||||
|
||||
|
||||
@callback
|
||||
def generate_power_sensor_entity_id(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
source_entity: SourceEntity | None = None,
|
||||
name: str | None = None,
|
||||
unique_id: str | None = None,
|
||||
) -> str:
|
||||
"""Generates the entity_id to use for a power sensor."""
|
||||
if entity_id := get_entity_id_by_unique_id(hass, unique_id):
|
||||
return entity_id
|
||||
name_pattern = str(sensor_config.get(CONF_POWER_SENSOR_NAMING, DEFAULT_POWER_NAME_PATTERN))
|
||||
object_id = name or sensor_config.get(CONF_NAME)
|
||||
if object_id is None and source_entity:
|
||||
object_id = source_entity.object_id
|
||||
return async_generate_entity_id(
|
||||
ENTITY_ID_FORMAT,
|
||||
name_pattern.format(object_id),
|
||||
hass=hass,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def generate_energy_sensor_entity_id(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
source_entity: SourceEntity | None = None,
|
||||
name: str | None = None,
|
||||
unique_id: str | None = None,
|
||||
) -> str:
|
||||
"""Generates the entity_id to use for an energy sensor."""
|
||||
if entity_id := get_entity_id_by_unique_id(hass, unique_id):
|
||||
return entity_id
|
||||
name_pattern = str(sensor_config.get(CONF_ENERGY_SENSOR_NAMING, DEFAULT_ENERGY_NAME_PATTERN))
|
||||
object_id = name or sensor_config.get(CONF_NAME)
|
||||
if object_id is None and source_entity:
|
||||
object_id = source_entity.object_id
|
||||
return async_generate_entity_id(
|
||||
ENTITY_ID_FORMAT,
|
||||
name_pattern.format(object_id),
|
||||
hass=hass,
|
||||
)
|
||||
|
||||
|
||||
def get_entity_id_by_unique_id(
|
||||
hass: HomeAssistant,
|
||||
unique_id: str | None,
|
||||
) -> str | None:
|
||||
if unique_id is None:
|
||||
return None
|
||||
entity_reg = er.async_get(hass)
|
||||
return entity_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id)
|
||||
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, time, timedelta
|
||||
import decimal
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import TemplateError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
import homeassistant.util.dt as dt_util
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_DAILY_FIXED_ENERGY,
|
||||
CONF_ENERGY_SENSOR_CATEGORY,
|
||||
CONF_ENERGY_SENSOR_PRECISION,
|
||||
CONF_ENERGY_SENSOR_UNIT_PREFIX,
|
||||
CONF_FIXED,
|
||||
CONF_ON_TIME,
|
||||
CONF_POWER,
|
||||
CONF_START_TIME,
|
||||
CONF_UPDATE_FREQUENCY,
|
||||
CONF_VALUE,
|
||||
DEFAULT_ENERGY_SENSOR_PRECISION,
|
||||
UnitPrefix,
|
||||
)
|
||||
|
||||
from .abstract import generate_energy_sensor_entity_id, generate_energy_sensor_name
|
||||
from .energy import EnergySensor
|
||||
from .power import VirtualPowerSensor, create_virtual_power_sensor
|
||||
|
||||
ENERGY_ICON = "mdi:lightning-bolt"
|
||||
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
|
||||
|
||||
DEFAULT_DAILY_UPDATE_FREQUENCY = 1800
|
||||
DAILY_FIXED_ENERGY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_VALUE): vol.Any(vol.Coerce(float), cv.template),
|
||||
vol.Optional(
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
default=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
): vol.In(
|
||||
[UnitOfEnergy.KILO_WATT_HOUR, UnitOfPower.WATT],
|
||||
),
|
||||
vol.Optional(CONF_ON_TIME, default=timedelta(days=1)): cv.time_period,
|
||||
vol.Optional(CONF_START_TIME): cv.time,
|
||||
vol.Optional(
|
||||
CONF_UPDATE_FREQUENCY,
|
||||
default=DEFAULT_DAILY_UPDATE_FREQUENCY,
|
||||
): vol.Coerce(int),
|
||||
},
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_daily_fixed_energy_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
source_entity: SourceEntity | None = None,
|
||||
) -> DailyEnergySensor:
|
||||
mode_config: ConfigType = sensor_config.get(CONF_DAILY_FIXED_ENERGY) # type: ignore
|
||||
|
||||
name = generate_energy_sensor_name(
|
||||
sensor_config,
|
||||
sensor_config.get(CONF_NAME),
|
||||
source_entity,
|
||||
)
|
||||
unique_id = sensor_config.get(CONF_UNIQUE_ID) or None
|
||||
entity_id = generate_energy_sensor_entity_id(
|
||||
hass,
|
||||
sensor_config,
|
||||
unique_id=unique_id,
|
||||
source_entity=source_entity,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Creating daily_fixed_energy energy sensor (name=%s, entity_id=%s, unique_id=%s)",
|
||||
name,
|
||||
entity_id,
|
||||
unique_id,
|
||||
)
|
||||
|
||||
if CONF_ON_TIME in mode_config:
|
||||
on_time = mode_config.get(CONF_ON_TIME)
|
||||
if not isinstance(on_time, timedelta):
|
||||
on_time = timedelta(seconds=on_time) # type: ignore
|
||||
else:
|
||||
on_time = timedelta(days=1)
|
||||
|
||||
return DailyEnergySensor(
|
||||
hass,
|
||||
name,
|
||||
entity_id,
|
||||
mode_config.get(CONF_VALUE), # type: ignore
|
||||
str(mode_config.get(CONF_UNIT_OF_MEASUREMENT)),
|
||||
int(mode_config.get(CONF_UPDATE_FREQUENCY, DEFAULT_DAILY_UPDATE_FREQUENCY)),
|
||||
sensor_config,
|
||||
on_time=on_time,
|
||||
start_time=mode_config.get(CONF_START_TIME),
|
||||
rounding_digits=int(sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION)),
|
||||
)
|
||||
|
||||
|
||||
async def create_daily_fixed_energy_power_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: dict,
|
||||
source_entity: SourceEntity,
|
||||
) -> VirtualPowerSensor | None:
|
||||
mode_config: dict = sensor_config.get(CONF_DAILY_FIXED_ENERGY) # type: ignore
|
||||
|
||||
if mode_config.get(CONF_ON_TIME) != timedelta(days=1):
|
||||
return None
|
||||
|
||||
power_value: float = mode_config.get(CONF_VALUE) # type: ignore
|
||||
if mode_config.get(CONF_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR and not isinstance(power_value, Template):
|
||||
power_value = power_value * 1000 / 24
|
||||
|
||||
power_sensor_config = sensor_config.copy()
|
||||
power_sensor_config[CONF_FIXED] = {CONF_POWER: power_value}
|
||||
|
||||
unique_id = sensor_config.get(CONF_UNIQUE_ID)
|
||||
if unique_id:
|
||||
power_sensor_config[CONF_UNIQUE_ID] = f"{unique_id}_power"
|
||||
|
||||
_LOGGER.debug(
|
||||
"Creating daily_fixed_energy power sensor (base_name=%s unique_id=%s)",
|
||||
sensor_config.get(CONF_NAME),
|
||||
unique_id,
|
||||
)
|
||||
|
||||
return await create_virtual_power_sensor(
|
||||
hass,
|
||||
power_sensor_config,
|
||||
source_entity,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
class DailyEnergySensor(RestoreEntity, SensorEntity, EnergySensor):
|
||||
_attr_device_class = SensorDeviceClass.ENERGY
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
_attr_should_poll = False
|
||||
_attr_icon = ENERGY_ICON
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
name: str,
|
||||
entity_id: str,
|
||||
value: float | Template,
|
||||
user_unit_of_measurement: str,
|
||||
update_frequency: int,
|
||||
sensor_config: dict[str, Any],
|
||||
on_time: timedelta | None = None,
|
||||
start_time: time | None = None,
|
||||
rounding_digits: int = 4,
|
||||
) -> None:
|
||||
self._hass = hass
|
||||
self._attr_name = name
|
||||
self._state: Decimal = Decimal(0)
|
||||
self._attr_entity_category = sensor_config.get(CONF_ENERGY_SENSOR_CATEGORY)
|
||||
self._value = value
|
||||
self._user_unit_of_measurement = user_unit_of_measurement
|
||||
self._update_frequency = update_frequency
|
||||
self._sensor_config = sensor_config
|
||||
self._on_time = on_time or timedelta(days=1)
|
||||
self._start_time = start_time
|
||||
self._rounding_digits = rounding_digits
|
||||
self._attr_suggested_display_precision = self._rounding_digits
|
||||
self._attr_unique_id = sensor_config.get(CONF_UNIQUE_ID)
|
||||
self.entity_id = entity_id
|
||||
self._last_updated: float = dt_util.utcnow().timestamp()
|
||||
self._last_delta_calculate: float | None = None
|
||||
self.set_native_unit_of_measurement()
|
||||
self._update_timer_removal: Callable[[], None] | None = None
|
||||
|
||||
def set_native_unit_of_measurement(self) -> None:
|
||||
"""Set the native unit of measurement."""
|
||||
unit_prefix = self._sensor_config.get(CONF_ENERGY_SENSOR_UNIT_PREFIX) or UnitPrefix.KILO
|
||||
if unit_prefix == UnitPrefix.KILO:
|
||||
self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
|
||||
elif unit_prefix == UnitPrefix.NONE:
|
||||
self._attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR
|
||||
elif unit_prefix == UnitPrefix.MEGA:
|
||||
self._attr_native_unit_of_measurement = UnitOfEnergy.MEGA_WATT_HOUR
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
if state := await self.async_get_last_state():
|
||||
try:
|
||||
self._state = Decimal(state.state)
|
||||
except decimal.DecimalException:
|
||||
_LOGGER.warning(
|
||||
"%s: Cannot restore state: %s",
|
||||
self.entity_id,
|
||||
state.state,
|
||||
)
|
||||
self._state = Decimal(0)
|
||||
self._last_updated = state.last_changed.timestamp()
|
||||
self._state += self.calculate_delta()
|
||||
self.async_schedule_update_ha_state()
|
||||
else:
|
||||
self._state = Decimal(0)
|
||||
|
||||
_LOGGER.debug("%s: Restoring state: %s", self.entity_id, self._state)
|
||||
|
||||
@callback
|
||||
def refresh(__: datetime) -> None:
|
||||
"""Update the energy sensor state."""
|
||||
delta = self.calculate_delta(self._update_frequency)
|
||||
if delta > 0:
|
||||
self._state = self._state + delta
|
||||
_LOGGER.debug(
|
||||
"%s: Updating daily_fixed_energy sensor: %.4f",
|
||||
self.entity_id,
|
||||
self._state,
|
||||
)
|
||||
self.async_schedule_update_ha_state()
|
||||
self._last_updated = dt_util.now().timestamp()
|
||||
|
||||
self._update_timer_removal = async_track_time_interval(
|
||||
self.hass,
|
||||
refresh,
|
||||
timedelta(seconds=self._update_frequency),
|
||||
)
|
||||
|
||||
def calculate_delta(self, elapsed_seconds: int = 0) -> Decimal:
|
||||
if self._last_delta_calculate is None:
|
||||
self._last_delta_calculate = self._last_updated
|
||||
|
||||
elapsed_seconds = (int(self._last_delta_calculate) - int(self._last_updated)) + elapsed_seconds
|
||||
self._last_delta_calculate = dt_util.utcnow().timestamp()
|
||||
|
||||
value = self._value
|
||||
if isinstance(value, Template):
|
||||
value.hass = self.hass
|
||||
try:
|
||||
value = float(value.async_render())
|
||||
except TemplateError as ex:
|
||||
_LOGGER.error(
|
||||
"%s: Could not render value template %s: %s",
|
||||
self.entity_id,
|
||||
value,
|
||||
ex,
|
||||
)
|
||||
return Decimal(0)
|
||||
|
||||
wh_per_day = value * (self._on_time.total_seconds() / 3600) if self._user_unit_of_measurement == UnitOfPower.WATT else value * 1000
|
||||
|
||||
# Convert Wh to the native measurement unit
|
||||
energy_per_day = wh_per_day
|
||||
if self._attr_native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR:
|
||||
energy_per_day = wh_per_day / 1000
|
||||
elif self._attr_native_unit_of_measurement == UnitOfEnergy.MEGA_WATT_HOUR:
|
||||
energy_per_day = wh_per_day / 1000000
|
||||
|
||||
return Decimal((energy_per_day / 86400) * elapsed_seconds)
|
||||
|
||||
@property
|
||||
def native_value(self) -> Decimal:
|
||||
"""Return the state of the sensor."""
|
||||
return Decimal(round(self._state, self._rounding_digits))
|
||||
|
||||
@callback
|
||||
def async_reset(self) -> None:
|
||||
_LOGGER.debug("%s: Reset energy sensor", self.entity_id)
|
||||
self._state = Decimal(0)
|
||||
self._attr_last_reset = dt_util.utcnow()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_increase(self, value: str) -> None:
|
||||
_LOGGER.debug("%s: Increasing energy sensor with %s", self.entity_id, value)
|
||||
self._state += Decimal(value)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_calibrate(self, value: str) -> None:
|
||||
_LOGGER.debug("%s: Calibrate energy sensor with %s", self.entity_id, value)
|
||||
self._state = Decimal(value)
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,373 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
import inspect
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.integration.sensor import IntegrationSensor
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONF_NAME,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.const import (
|
||||
ATTR_SOURCE_DOMAIN,
|
||||
ATTR_SOURCE_ENTITY,
|
||||
CONF_DISABLE_EXTENDED_ATTRIBUTES,
|
||||
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
|
||||
CONF_ENERGY_FILTER_OUTLIER_MAX,
|
||||
CONF_ENERGY_INTEGRATION_METHOD,
|
||||
CONF_ENERGY_SENSOR_CATEGORY,
|
||||
CONF_ENERGY_SENSOR_ID,
|
||||
CONF_ENERGY_SENSOR_PRECISION,
|
||||
CONF_ENERGY_SENSOR_UNIT_PREFIX,
|
||||
CONF_ENERGY_UPDATE_INTERVAL,
|
||||
CONF_FORCE_ENERGY_SENSOR_CREATION,
|
||||
CONF_POWER_SENSOR_ID,
|
||||
DEFAULT_ENERGY_INTEGRATION_METHOD,
|
||||
DEFAULT_ENERGY_SENSOR_PRECISION,
|
||||
DEFAULT_ENERGY_UPDATE_INTERVAL,
|
||||
UnitPrefix,
|
||||
)
|
||||
from custom_components.powercalc.device_binding import get_device_info
|
||||
from custom_components.powercalc.errors import SensorConfigurationError
|
||||
from custom_components.powercalc.filter.outlier import OutlierFilter
|
||||
|
||||
from .abstract import (
|
||||
BaseEntity,
|
||||
generate_energy_sensor_entity_id,
|
||||
generate_energy_sensor_name,
|
||||
)
|
||||
from .power import PowerSensor, RealPowerSensor
|
||||
|
||||
ENERGY_ICON = "mdi:lightning-bolt"
|
||||
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_energy_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
power_sensor: PowerSensor,
|
||||
source_entity: SourceEntity | None = None,
|
||||
) -> EnergySensor:
|
||||
"""Create the energy sensor entity."""
|
||||
|
||||
# Check for existing energy sensor
|
||||
energy_sensor = await _get_existing_energy_sensor(hass, sensor_config)
|
||||
if energy_sensor:
|
||||
return energy_sensor
|
||||
|
||||
# Check if we should find or create a related energy sensor
|
||||
energy_sensor = await _get_related_energy_sensor(hass, sensor_config, power_sensor)
|
||||
if energy_sensor:
|
||||
return energy_sensor
|
||||
|
||||
# Create a new virtual energy sensor based on the virtual power sensor
|
||||
return await _create_virtual_energy_sensor(hass, sensor_config, power_sensor, source_entity)
|
||||
|
||||
|
||||
async def _get_existing_energy_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
) -> EnergySensor | None:
|
||||
"""Check if the user specified an existing energy sensor."""
|
||||
if CONF_ENERGY_SENSOR_ID not in sensor_config:
|
||||
return None
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
energy_sensor_id = sensor_config[CONF_ENERGY_SENSOR_ID]
|
||||
entity_entry = ent_reg.async_get(energy_sensor_id)
|
||||
if entity_entry is None:
|
||||
raise SensorConfigurationError(
|
||||
f"No energy sensor with id {energy_sensor_id} found in your HA instance. Double check `energy_sensor_id` setting",
|
||||
)
|
||||
return RealEnergySensor(
|
||||
entity_entry.entity_id,
|
||||
entity_entry.name or entity_entry.original_name,
|
||||
entity_entry.unique_id,
|
||||
)
|
||||
|
||||
|
||||
async def _get_related_energy_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
power_sensor: PowerSensor,
|
||||
) -> EnergySensor | None:
|
||||
"""Find or create a related energy sensor based on the power sensor."""
|
||||
|
||||
if CONF_POWER_SENSOR_ID not in sensor_config or not isinstance(power_sensor, RealPowerSensor):
|
||||
return None
|
||||
|
||||
if sensor_config.get(CONF_FORCE_ENERGY_SENSOR_CREATION):
|
||||
_LOGGER.debug(
|
||||
"Forced energy sensor generation for the power sensor '%s'",
|
||||
power_sensor.entity_id,
|
||||
)
|
||||
return None
|
||||
|
||||
real_energy_sensor = _find_related_real_energy_sensor(hass, power_sensor)
|
||||
if real_energy_sensor:
|
||||
_LOGGER.debug(
|
||||
"Found existing energy sensor '%s' for the power sensor '%s'",
|
||||
real_energy_sensor.entity_id,
|
||||
power_sensor.entity_id,
|
||||
)
|
||||
return real_energy_sensor
|
||||
|
||||
_LOGGER.debug(
|
||||
"No existing energy sensor found for the power sensor '%s'",
|
||||
power_sensor.entity_id,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _create_virtual_energy_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
power_sensor: PowerSensor,
|
||||
source_entity: SourceEntity | None,
|
||||
) -> VirtualEnergySensor:
|
||||
"""Create a virtual energy sensor using riemann integral integration."""
|
||||
name = generate_energy_sensor_name(
|
||||
sensor_config,
|
||||
sensor_config.get(CONF_NAME),
|
||||
source_entity,
|
||||
)
|
||||
unique_id = f"{power_sensor.unique_id}_energy" if power_sensor.unique_id is not None else None
|
||||
entity_id = generate_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 energy sensor (entity_id=%s, source_entity=%s, unit_prefix=%s)",
|
||||
entity_id,
|
||||
power_sensor.entity_id,
|
||||
unit_prefix,
|
||||
)
|
||||
|
||||
return VirtualEnergySensor(
|
||||
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 if source_entity else None,
|
||||
powercalc_source_domain=source_entity.domain if source_entity else None,
|
||||
sensor_config=sensor_config,
|
||||
device_info=get_device_info(hass, sensor_config, source_entity),
|
||||
)
|
||||
|
||||
|
||||
def get_unit_prefix(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
power_sensor: PowerSensor,
|
||||
) -> str | None:
|
||||
unit_prefix = sensor_config.get(CONF_ENERGY_SENSOR_UNIT_PREFIX)
|
||||
|
||||
power_unit = UnitOfPower(power_sensor.unit_of_measurement) # type: ignore
|
||||
power_state = hass.states.get(power_sensor.entity_id)
|
||||
if power_unit is None and power_state: # type: ignore
|
||||
power_unit = power_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # type: ignore # pragma: no cover
|
||||
|
||||
# When the power sensor is in kW, we don't want to add an extra k prefix.
|
||||
# As this would result in an energy sensor having kkWh unit, which is obviously invalid
|
||||
if power_unit == UnitOfPower.KILO_WATT and unit_prefix == UnitPrefix.KILO:
|
||||
unit_prefix = UnitPrefix.NONE
|
||||
|
||||
if unit_prefix == UnitPrefix.NONE:
|
||||
unit_prefix = None
|
||||
return unit_prefix
|
||||
|
||||
|
||||
@callback
|
||||
def _find_related_real_energy_sensor(
|
||||
hass: HomeAssistant,
|
||||
power_sensor: RealPowerSensor,
|
||||
) -> RealEnergySensor | None:
|
||||
"""See if a corresponding energy sensor exists in the HA installation for the power sensor."""
|
||||
if not power_sensor.device_id:
|
||||
return None
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
energy_sensors = [
|
||||
entry
|
||||
for entry in er.async_entries_for_device(
|
||||
ent_reg,
|
||||
device_id=power_sensor.device_id,
|
||||
)
|
||||
if entry.device_class == SensorDeviceClass.ENERGY or entry.unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR
|
||||
]
|
||||
if not energy_sensors:
|
||||
return None
|
||||
|
||||
entity_entry = energy_sensors[0]
|
||||
return RealEnergySensor(
|
||||
entity_entry.entity_id,
|
||||
entity_entry.name or entity_entry.original_name,
|
||||
entity_entry.unique_id,
|
||||
)
|
||||
|
||||
|
||||
class EnergySensor(BaseEntity):
|
||||
"""Class which all energy sensors should extend from."""
|
||||
|
||||
|
||||
class VirtualEnergySensor(IntegrationSensor, EnergySensor):
|
||||
"""Virtual energy sensor, totalling kWh."""
|
||||
|
||||
_attr_state_class = SensorStateClass.TOTAL_INCREASING
|
||||
_unrecorded_attributes = frozenset({ATTR_SOURCE_DOMAIN, ATTR_SOURCE_ENTITY})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
source_entity: str,
|
||||
entity_id: str,
|
||||
sensor_config: ConfigType,
|
||||
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,
|
||||
device_info: DeviceInfo | None = None,
|
||||
) -> None:
|
||||
round_digits: int = int(sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION))
|
||||
integration_method: str = sensor_config.get(CONF_ENERGY_INTEGRATION_METHOD, DEFAULT_ENERGY_INTEGRATION_METHOD)
|
||||
|
||||
params = {
|
||||
"hass": hass,
|
||||
"source_entity": source_entity,
|
||||
"name": name,
|
||||
"round_digits": round_digits,
|
||||
"unit_prefix": unit_prefix,
|
||||
"unit_time": UnitOfTime.HOURS,
|
||||
"integration_method": integration_method,
|
||||
"unique_id": unique_id,
|
||||
"device_info": device_info,
|
||||
"max_sub_interval": timedelta(seconds=sensor_config.get(CONF_ENERGY_UPDATE_INTERVAL, DEFAULT_ENERGY_UPDATE_INTERVAL)),
|
||||
}
|
||||
|
||||
signature = inspect.signature(IntegrationSensor.__init__)
|
||||
|
||||
params = {key: val for key, val in params.items() if key in signature.parameters}
|
||||
|
||||
super().__init__(**params) # type: ignore[arg-type]
|
||||
|
||||
self._powercalc_source_entity = powercalc_source_entity
|
||||
self._powercalc_source_domain = powercalc_source_domain
|
||||
self._sensor_config = sensor_config
|
||||
self.entity_id = entity_id
|
||||
self._attr_device_class = SensorDeviceClass.ENERGY
|
||||
self._attr_suggested_display_precision = round_digits
|
||||
if entity_category:
|
||||
self._attr_entity_category = EntityCategory(entity_category)
|
||||
self._filter_outliers = bool(sensor_config.get(CONF_ENERGY_FILTER_OUTLIER_ENABLED, False))
|
||||
self._outlier_filter = OutlierFilter(
|
||||
window_size=30,
|
||||
min_samples=5,
|
||||
max_z_score=3.5,
|
||||
max_expected_step=sensor_config.get(CONF_ENERGY_FILTER_OUTLIER_MAX, 1000),
|
||||
)
|
||||
|
||||
def _integrate_on_state_change(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
|
||||
"""Override to add outlier filtering."""
|
||||
|
||||
new_state: State | None = kwargs.get("new_state")
|
||||
if new_state is None and args:
|
||||
last_arg = args[-1]
|
||||
if isinstance(last_arg, State):
|
||||
new_state = last_arg
|
||||
|
||||
if self._filter_outliers and new_state is not None:
|
||||
valid_state = new_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
if valid_state and not self._outlier_filter.accept(float(new_state.state)):
|
||||
_LOGGER.debug(
|
||||
"%s: Rejecting power value %s as outlier for energy integration",
|
||||
self.entity_id,
|
||||
new_state.state,
|
||||
)
|
||||
return
|
||||
|
||||
super()._integrate_on_state_change(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str] | None:
|
||||
"""Return the state attributes of the energy sensor."""
|
||||
if self._sensor_config.get(CONF_DISABLE_EXTENDED_ATTRIBUTES):
|
||||
return super().extra_state_attributes
|
||||
|
||||
if self._powercalc_source_entity is None:
|
||||
return None
|
||||
|
||||
attrs = {
|
||||
ATTR_SOURCE_ENTITY: self._powercalc_source_entity or "",
|
||||
ATTR_SOURCE_DOMAIN: self._powercalc_source_domain or "",
|
||||
}
|
||||
super_attrs = super().extra_state_attributes
|
||||
if super_attrs:
|
||||
attrs.update(super_attrs)
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
return ENERGY_ICON
|
||||
|
||||
@callback
|
||||
def async_reset(self) -> None:
|
||||
_LOGGER.debug("%s: Reset energy sensor", self.entity_id)
|
||||
self._state = Decimal(0)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_calibrate(self, value: str) -> None:
|
||||
_LOGGER.debug("%s: Calibrate energy sensor to: %s", self.entity_id, value)
|
||||
self._state = Decimal(value)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class RealEnergySensor(EnergySensor):
|
||||
"""Contains a reference to an existing energy sensor entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entity_id: str,
|
||||
name: str | None = None,
|
||||
unique_id: str | None = None,
|
||||
) -> None:
|
||||
self.entity_id = entity_id
|
||||
self._name = name
|
||||
self._unique_id = unique_id
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return the unique_id of the sensor."""
|
||||
return self._unique_id
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,157 @@
|
||||
import inspect
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigFlow
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_GROUP,
|
||||
CONF_GROUP_MEMBER_SENSORS,
|
||||
CONF_GROUP_TYPE,
|
||||
CONF_SENSOR_TYPE,
|
||||
CONF_SUB_GROUPS,
|
||||
DOMAIN,
|
||||
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
|
||||
GroupType,
|
||||
SensorType,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def remove_power_sensor_from_associated_groups(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
) -> list[ConfigEntry]:
|
||||
"""When the user remove a virtual power config entry we need to update all the groups which this sensor belongs to."""
|
||||
group_entries = get_groups_having_member(hass, config_entry)
|
||||
|
||||
for group_entry in group_entries:
|
||||
member_sensors = group_entry.data.get(CONF_GROUP_MEMBER_SENSORS) or []
|
||||
member_sensors.remove(config_entry.entry_id)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
group_entry,
|
||||
data={**group_entry.data, CONF_GROUP_MEMBER_SENSORS: member_sensors},
|
||||
)
|
||||
|
||||
return group_entries
|
||||
|
||||
|
||||
async def add_to_associated_groups(hass: HomeAssistant, config_entry: ConfigEntry) -> ConfigEntry | None: # type: ignore
|
||||
"""
|
||||
When the user has set a group on a virtual power config entry,
|
||||
we need to add this config entry to the group members sensors and update the group.
|
||||
"""
|
||||
sensor_type = config_entry.data.get(CONF_SENSOR_TYPE)
|
||||
if sensor_type not in [SensorType.VIRTUAL_POWER, SensorType.DAILY_ENERGY]:
|
||||
return None
|
||||
|
||||
raw_groups = config_entry.data.get(CONF_GROUP)
|
||||
if not raw_groups:
|
||||
return None
|
||||
|
||||
group_ids = raw_groups if isinstance(raw_groups, list) else [raw_groups]
|
||||
for group_entry_id in group_ids:
|
||||
group_entry = await add_to_associated_group(hass, config_entry, group_entry_id)
|
||||
if group_entry:
|
||||
_LOGGER.debug(
|
||||
"ConfigEntry %s: Added to group %s.",
|
||||
config_entry.title,
|
||||
group_entry.title,
|
||||
)
|
||||
|
||||
# After processed correctly we can want to unset the group, to prevent is being processed again
|
||||
new_data = {k: v for k, v in config_entry.data.items() if k != CONF_GROUP}
|
||||
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||
|
||||
|
||||
async def add_to_associated_group(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
group_entry_id: str,
|
||||
) -> ConfigEntry | None:
|
||||
"""When the user has set a group on a virtual power config entry,
|
||||
we need to add this config entry to the group members sensors and update the group.
|
||||
"""
|
||||
group_entry = hass.config_entries.async_get_entry(group_entry_id)
|
||||
|
||||
# When we are not dealing with a uuid, the user has set a group name manually
|
||||
# Create a new group entry for this group
|
||||
if not group_entry and len(group_entry_id) != 32:
|
||||
group_entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, group_entry_id)
|
||||
if not group_entry:
|
||||
additional_args: dict = {}
|
||||
signature = inspect.signature(ConfigEntry.__init__)
|
||||
if "discovery_keys" in signature.parameters:
|
||||
additional_args["discovery_keys"] = {}
|
||||
|
||||
if "subentries_data" in signature.parameters:
|
||||
additional_args["subentries_data"] = None
|
||||
|
||||
group_entry = ConfigEntry(
|
||||
version=ConfigFlow.VERSION,
|
||||
minor_version=ConfigFlow.MINOR_VERSION,
|
||||
domain=DOMAIN,
|
||||
source=SOURCE_IMPORT,
|
||||
title=group_entry_id,
|
||||
data={
|
||||
CONF_SENSOR_TYPE: SensorType.GROUP,
|
||||
CONF_NAME: group_entry_id,
|
||||
},
|
||||
options={},
|
||||
unique_id=group_entry_id,
|
||||
**additional_args,
|
||||
)
|
||||
await hass.config_entries.async_add(group_entry)
|
||||
|
||||
if not group_entry:
|
||||
_LOGGER.warning(
|
||||
"ConfigEntry %s: Cannot add/remove to group %s. It does not exist.",
|
||||
config_entry.title,
|
||||
group_entry_id,
|
||||
)
|
||||
return None
|
||||
|
||||
member_sensors = set(group_entry.data.get(CONF_GROUP_MEMBER_SENSORS) or [])
|
||||
|
||||
# Config entry has already been added to associated group. just skip adding it again
|
||||
if config_entry.entry_id in member_sensors:
|
||||
return None
|
||||
|
||||
member_sensors.add(config_entry.entry_id)
|
||||
hass.config_entries.async_update_entry(
|
||||
group_entry,
|
||||
data={**group_entry.data, CONF_GROUP_MEMBER_SENSORS: list(member_sensors)},
|
||||
)
|
||||
return group_entry
|
||||
|
||||
|
||||
def get_entries_having_subgroup(hass: HomeAssistant, subgroup_entry: ConfigEntry) -> list[ConfigEntry]:
|
||||
"""Get all virtual power entries which have the subgroup in their subgroups list."""
|
||||
return [entry for entry in get_group_entries(hass) if subgroup_entry.entry_id in (entry.data.get(CONF_SUB_GROUPS) or [])]
|
||||
|
||||
|
||||
def get_groups_having_member(hass: HomeAssistant, member_entry: ConfigEntry) -> list[ConfigEntry]:
|
||||
"""Get all group entries which have the member sensor in their member list."""
|
||||
return [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.data.get(CONF_SENSOR_TYPE) == SensorType.GROUP and member_entry.entry_id in (entry.data.get(CONF_GROUP_MEMBER_SENSORS) or [])
|
||||
]
|
||||
|
||||
|
||||
@callback
|
||||
def get_group_entries(hass: HomeAssistant, group_type: GroupType | None = None) -> list[ConfigEntry]:
|
||||
return [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.data.get(CONF_SENSOR_TYPE) == SensorType.GROUP
|
||||
and (group_type is None or entry.data.get(CONF_GROUP_TYPE, GroupType.CUSTOM) == group_type)
|
||||
]
|
||||
|
||||
|
||||
@callback
|
||||
def get_entries_excluding_global_config(hass: HomeAssistant) -> list[ConfigEntry]:
|
||||
return [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != ENTRY_GLOBAL_CONFIG_UNIQUE_ID]
|
||||
@@ -0,0 +1,979 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, DecimalException
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
RestoreSensor,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONF_DEVICE,
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, start
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.event import (
|
||||
EventStateChangedData,
|
||||
async_call_later,
|
||||
async_track_state_change_event,
|
||||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.unit_conversion import (
|
||||
BaseUnitConverter,
|
||||
EnergyConverter,
|
||||
PowerConverter,
|
||||
)
|
||||
|
||||
from custom_components.powercalc.analytics.analytics import collect_analytics
|
||||
from custom_components.powercalc.const import (
|
||||
ATTR_ENTITIES,
|
||||
ATTR_IS_GROUP,
|
||||
CONF_ALL,
|
||||
CONF_AREA,
|
||||
CONF_CREATE_ENERGY_SENSOR,
|
||||
CONF_CREATE_GROUP,
|
||||
CONF_DISABLE_EXTENDED_ATTRIBUTES,
|
||||
CONF_ENERGY_SENSOR_PRECISION,
|
||||
CONF_ENERGY_SENSOR_UNIT_PREFIX,
|
||||
CONF_EXCLUDE_ENTITIES,
|
||||
CONF_FLOOR,
|
||||
CONF_FORCE_CALCULATE_GROUP_ENERGY,
|
||||
CONF_GROUP_ENERGY_ENTITIES,
|
||||
CONF_GROUP_ENERGY_START_AT_ZERO,
|
||||
CONF_GROUP_ENERGY_UPDATE_INTERVAL,
|
||||
CONF_GROUP_MEMBER_DEVICES,
|
||||
CONF_GROUP_MEMBER_SENSORS,
|
||||
CONF_GROUP_POWER_ENTITIES,
|
||||
CONF_GROUP_POWER_UPDATE_INTERVAL,
|
||||
CONF_GROUP_TYPE,
|
||||
CONF_HIDE_MEMBERS,
|
||||
CONF_IGNORE_UNAVAILABLE_STATE,
|
||||
CONF_INCLUDE_NON_POWERCALC_SENSORS,
|
||||
CONF_POWER_SENSOR_PRECISION,
|
||||
CONF_SENSOR_TYPE,
|
||||
CONF_SUB_GROUPS,
|
||||
CONF_UTILITY_METER_NET_CONSUMPTION,
|
||||
DATA_DOMAIN_ENTITIES,
|
||||
DATA_GROUP_SIZES,
|
||||
DEFAULT_ENERGY_SENSOR_PRECISION,
|
||||
DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL,
|
||||
DEFAULT_GROUP_POWER_UPDATE_INTERVAL,
|
||||
DEFAULT_POWER_SENSOR_PRECISION,
|
||||
DOMAIN,
|
||||
ENTRY_DATA_ENERGY_ENTITY,
|
||||
ENTRY_DATA_POWER_ENTITY,
|
||||
SERVICE_RESET_ENERGY,
|
||||
GroupType,
|
||||
SensorType,
|
||||
UnitPrefix,
|
||||
)
|
||||
from custom_components.powercalc.device_binding import get_device_info
|
||||
from custom_components.powercalc.group_include.filter import AreaFilter, CompositeFilter, DeviceFilter, EntityFilter, FilterOperator, FloorFilter
|
||||
from custom_components.powercalc.group_include.include import find_entities
|
||||
from custom_components.powercalc.helpers import async_cache
|
||||
from custom_components.powercalc.sensors.abstract import (
|
||||
BaseEntity,
|
||||
generate_energy_sensor_entity_id,
|
||||
generate_energy_sensor_name,
|
||||
generate_power_sensor_entity_id,
|
||||
generate_power_sensor_name,
|
||||
)
|
||||
from custom_components.powercalc.sensors.energy import EnergySensor, VirtualEnergySensor
|
||||
from custom_components.powercalc.sensors.power import PowerSensor
|
||||
from custom_components.powercalc.sensors.utility_meter import create_utility_meters
|
||||
|
||||
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
STORAGE_KEY = "powercalc_group"
|
||||
STORAGE_VERSION = 2
|
||||
# How long between periodically saving the current states to disk
|
||||
STATE_DUMP_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
ENERGY_UNIT_PREFIX_MAPPING = {
|
||||
UnitPrefix.KILO: UnitOfEnergy.KILO_WATT_HOUR,
|
||||
UnitPrefix.MEGA: UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
UnitPrefix.NONE: UnitOfEnergy.WATT_HOUR,
|
||||
}
|
||||
|
||||
UNIT_CONVERTERS: dict[str | None, type[BaseUnitConverter]] = {
|
||||
**dict.fromkeys(EnergyConverter.VALID_UNITS, EnergyConverter),
|
||||
**dict.fromkeys(PowerConverter.VALID_UNITS, PowerConverter),
|
||||
}
|
||||
|
||||
|
||||
async def create_group_sensors_yaml(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: dict[str, Any],
|
||||
entities: list[Entity],
|
||||
filters: list[Callable] | None = None,
|
||||
) -> list[Entity]:
|
||||
"""Create grouped power and energy sensors."""
|
||||
power_sensor_ids = filter_entity_list_by_class(entities, SensorDeviceClass.POWER, filters)
|
||||
|
||||
create_energy_sensor: bool = sensor_config.get(CONF_CREATE_ENERGY_SENSOR, True)
|
||||
energy_sensor_ids: set[str] = set()
|
||||
if create_energy_sensor:
|
||||
energy_sensor_ids = filter_entity_list_by_class(
|
||||
entities,
|
||||
SensorDeviceClass.ENERGY,
|
||||
filters,
|
||||
)
|
||||
|
||||
group_name = str(sensor_config.get(CONF_CREATE_GROUP))
|
||||
return await create_group_sensors_custom(hass, group_name, sensor_config, power_sensor_ids, energy_sensor_ids)
|
||||
|
||||
|
||||
async def create_group_sensors_gui(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
sensor_config: dict,
|
||||
) -> list[Entity]:
|
||||
"""Create group sensors based on a config_entry."""
|
||||
group_name = str(entry.data.get(CONF_NAME))
|
||||
|
||||
unique_id = sensor_config.get(CONF_UNIQUE_ID)
|
||||
if not unique_id:
|
||||
sensor_config[CONF_UNIQUE_ID] = entry.entry_id # pragma: nocover
|
||||
|
||||
power_sensor_ids = await resolve_entity_ids_recursively(hass, entry, SensorDeviceClass.POWER)
|
||||
|
||||
energy_sensor_ids = await resolve_entity_ids_recursively(hass, entry, SensorDeviceClass.ENERGY)
|
||||
|
||||
return await create_group_sensors_custom(hass, group_name, sensor_config, power_sensor_ids, energy_sensor_ids)
|
||||
|
||||
|
||||
async def create_group_sensors_custom(
|
||||
hass: HomeAssistant,
|
||||
group_name: str,
|
||||
sensor_config: dict[str, Any],
|
||||
power_sensor_ids: set[str],
|
||||
energy_sensor_ids: set[str],
|
||||
force_create: bool = False,
|
||||
) -> list[Entity]:
|
||||
"""Create grouped power and energy sensors."""
|
||||
|
||||
group_sensors: list[Entity] = []
|
||||
if CONF_NAME not in sensor_config:
|
||||
sensor_config[CONF_NAME] = group_name
|
||||
|
||||
group_type: GroupType = GroupType(sensor_config.get(CONF_GROUP_TYPE, GroupType.CUSTOM))
|
||||
|
||||
power_sensor = None
|
||||
if power_sensor_ids or force_create:
|
||||
power_sensor = create_grouped_power_sensor(
|
||||
hass,
|
||||
group_name,
|
||||
group_type,
|
||||
sensor_config,
|
||||
power_sensor_ids,
|
||||
)
|
||||
group_sensors.append(power_sensor)
|
||||
|
||||
create_energy_sensor: bool = sensor_config.get(CONF_CREATE_ENERGY_SENSOR, True)
|
||||
if create_energy_sensor:
|
||||
energy_sensor = create_grouped_energy_sensor(
|
||||
hass,
|
||||
group_name,
|
||||
group_type,
|
||||
sensor_config,
|
||||
energy_sensor_ids,
|
||||
power_sensor,
|
||||
)
|
||||
|
||||
group_sensors.append(energy_sensor)
|
||||
|
||||
sensor_config[CONF_UTILITY_METER_NET_CONSUMPTION] = True
|
||||
group_sensors.extend(
|
||||
await create_utility_meters(
|
||||
hass,
|
||||
energy_sensor,
|
||||
sensor_config,
|
||||
),
|
||||
)
|
||||
|
||||
collect_analytics(hass, None).add(DATA_GROUP_SIZES, len(power_sensor_ids) + len(energy_sensor_ids))
|
||||
|
||||
return group_sensors
|
||||
|
||||
|
||||
def filter_entity_list_by_class(
|
||||
all_entities: list,
|
||||
device_class: SensorDeviceClass,
|
||||
default_filters: list[Callable] | None = None,
|
||||
) -> set[str]:
|
||||
"""Filter entity list to only include entities of the given class."""
|
||||
class_name = PowerSensor if device_class == SensorDeviceClass.POWER else EnergySensor
|
||||
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))
|
||||
return {
|
||||
x.entity_id
|
||||
for x in filter(
|
||||
lambda x: all(f(x) for f in filter_list),
|
||||
all_entities,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@async_cache
|
||||
async def build_entity_include_filter(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
) -> EntityFilter:
|
||||
"""Build and cache the entity filter based on the entry data."""
|
||||
filters: list[EntityFilter] = []
|
||||
if CONF_AREA in entry.data:
|
||||
filters.append(AreaFilter(hass, entry.data[CONF_AREA]))
|
||||
if CONF_FLOOR in entry.data:
|
||||
filters.append(FloorFilter(hass, entry.data[CONF_FLOOR]))
|
||||
if CONF_GROUP_MEMBER_DEVICES in entry.data:
|
||||
filters.append(DeviceFilter(set(entry.data[CONF_GROUP_MEMBER_DEVICES])))
|
||||
return CompositeFilter(filters, FilterOperator.OR)
|
||||
|
||||
|
||||
async def resolve_entity_ids_recursively(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
device_class: SensorDeviceClass,
|
||||
resolved_ids: set[str] | None = None,
|
||||
) -> set[str]:
|
||||
"""Get all the entity IDs for the current group and all the subgroups."""
|
||||
if resolved_ids is None:
|
||||
resolved_ids = set()
|
||||
|
||||
def add_member_entry_ids() -> None:
|
||||
"""Add power/energy sensors from the group member entries."""
|
||||
member_entry_ids = entry.data.get(CONF_GROUP_MEMBER_SENSORS) or []
|
||||
for member_entry_id in member_entry_ids:
|
||||
member_entry = hass.config_entries.async_get_entry(member_entry_id)
|
||||
if member_entry is None:
|
||||
continue
|
||||
|
||||
key = resolve_key_based_on_device_class(member_entry)
|
||||
if key and key in member_entry.data:
|
||||
resolved_ids.add(str(member_entry.data.get(key)))
|
||||
|
||||
def resolve_key_based_on_device_class(member_entry: ConfigEntry) -> str | None:
|
||||
"""Resolve the correct key for power/energy sensor based on device class."""
|
||||
if member_entry.data.get(CONF_SENSOR_TYPE) == SensorType.REAL_POWER:
|
||||
return CONF_ENTITY_ID if device_class == SensorDeviceClass.POWER else ENTRY_DATA_ENERGY_ENTITY
|
||||
return ENTRY_DATA_POWER_ENTITY if device_class == SensorDeviceClass.POWER else ENTRY_DATA_ENERGY_ENTITY
|
||||
|
||||
def add_specified_sensors() -> None:
|
||||
"""Add additional power/energy sensors specified by the user."""
|
||||
conf_key = CONF_GROUP_POWER_ENTITIES if device_class == SensorDeviceClass.POWER else CONF_GROUP_ENERGY_ENTITIES
|
||||
resolved_ids.update(entry.data.get(conf_key) or [])
|
||||
|
||||
async def add_include_based_sensors() -> None:
|
||||
"""Add entities from the defined areas, devices and floors."""
|
||||
if all(k not in entry.data for k in (CONF_AREA, CONF_FLOOR, CONF_GROUP_MEMBER_DEVICES)):
|
||||
return
|
||||
|
||||
result = await find_entities(
|
||||
hass,
|
||||
await build_entity_include_filter(hass, entry),
|
||||
bool(entry.data.get(CONF_INCLUDE_NON_POWERCALC_SENSORS)),
|
||||
)
|
||||
resolved_ids.update(filter_entity_list_by_class(result.resolved, device_class))
|
||||
|
||||
async def add_subgroup_entities() -> None:
|
||||
"""Recursively add entities from subgroups."""
|
||||
subgroups = entry.data.get(CONF_SUB_GROUPS)
|
||||
if not subgroups:
|
||||
return
|
||||
|
||||
for subgroup_entry_id in subgroups:
|
||||
subgroup_entry = hass.config_entries.async_get_entry(subgroup_entry_id)
|
||||
if subgroup_entry is None:
|
||||
_LOGGER.error("Subgroup config entry not found: %s", subgroup_entry_id)
|
||||
continue
|
||||
|
||||
await resolve_entity_ids_recursively(hass, subgroup_entry, device_class, resolved_ids)
|
||||
|
||||
# Process the main logic
|
||||
add_member_entry_ids()
|
||||
add_specified_sensors()
|
||||
await add_include_based_sensors()
|
||||
await add_subgroup_entities()
|
||||
|
||||
return resolved_ids
|
||||
|
||||
|
||||
@callback
|
||||
def create_grouped_power_sensor(
|
||||
hass: HomeAssistant,
|
||||
group_name: str,
|
||||
group_type: GroupType,
|
||||
sensor_config: dict,
|
||||
power_sensor_ids: set[str],
|
||||
) -> GroupedPowerSensor:
|
||||
name = generate_power_sensor_name(sensor_config, group_name)
|
||||
unique_id = sensor_config.get(CONF_UNIQUE_ID)
|
||||
if not unique_id:
|
||||
unique_id = generate_unique_id(sensor_config)
|
||||
entity_id = generate_power_sensor_entity_id(
|
||||
hass,
|
||||
sensor_config,
|
||||
name=group_name,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Creating grouped power sensor: %s (entity_id=%s, unique_id=%s)", name, entity_id, unique_id)
|
||||
|
||||
return GroupedPowerSensor(
|
||||
hass=hass,
|
||||
name=name,
|
||||
entities=power_sensor_ids,
|
||||
unique_id=unique_id,
|
||||
sensor_config=sensor_config,
|
||||
group_type=group_type,
|
||||
entity_id=entity_id,
|
||||
device_id=sensor_config.get(CONF_DEVICE),
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def create_grouped_energy_sensor(
|
||||
hass: HomeAssistant,
|
||||
group_name: str,
|
||||
group_type: GroupType,
|
||||
sensor_config: dict,
|
||||
energy_sensor_ids: set[str],
|
||||
power_sensor: GroupedPowerSensor | None,
|
||||
) -> EnergySensor:
|
||||
name = generate_energy_sensor_name(sensor_config, group_name)
|
||||
unique_id = sensor_config.get(CONF_UNIQUE_ID)
|
||||
energy_unique_id = None
|
||||
if unique_id:
|
||||
energy_unique_id = f"{unique_id}_energy"
|
||||
entity_id = generate_energy_sensor_entity_id(
|
||||
hass,
|
||||
sensor_config,
|
||||
name=group_name,
|
||||
unique_id=energy_unique_id,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Creating grouped energy sensor: %s (entity_id=%s)", name, entity_id)
|
||||
|
||||
should_create_riemann = bool(sensor_config.get(CONF_FORCE_CALCULATE_GROUP_ENERGY, False))
|
||||
if not should_create_riemann and not energy_sensor_ids:
|
||||
should_create_riemann = True
|
||||
if group_type == GroupType.DOMAIN and sensor_config.get(CONF_DOMAIN) == "all":
|
||||
should_create_riemann = False
|
||||
if power_sensor and should_create_riemann:
|
||||
return VirtualEnergySensor(
|
||||
hass=hass,
|
||||
source_entity=power_sensor.entity_id,
|
||||
entity_id=entity_id,
|
||||
name=name,
|
||||
unique_id=energy_unique_id,
|
||||
sensor_config=sensor_config,
|
||||
device_info=get_device_info(hass, sensor_config, None),
|
||||
unit_prefix=sensor_config.get(CONF_ENERGY_SENSOR_UNIT_PREFIX, UnitPrefix.NONE),
|
||||
)
|
||||
|
||||
return GroupedEnergySensor(
|
||||
hass=hass,
|
||||
name=name,
|
||||
entities=energy_sensor_ids,
|
||||
unique_id=energy_unique_id,
|
||||
sensor_config=sensor_config,
|
||||
group_type=group_type,
|
||||
entity_id=entity_id,
|
||||
device_id=sensor_config.get(CONF_DEVICE),
|
||||
)
|
||||
|
||||
|
||||
def generate_unique_id(sensor_config: dict[str, Any]) -> str:
|
||||
return str(sensor_config[CONF_NAME])
|
||||
|
||||
|
||||
class GroupedSensor(BaseEntity, SensorEntity):
|
||||
"""Base class for grouped sensors."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_unrecorded_attributes = frozenset({ATTR_ENTITIES, ATTR_IS_GROUP})
|
||||
_is_energy_sensor = False
|
||||
_attr_force_update = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
name: str,
|
||||
entities: set[str],
|
||||
entity_id: str,
|
||||
sensor_config: dict[str, Any],
|
||||
group_type: GroupType,
|
||||
unique_id: str | None = None,
|
||||
device_id: str | None = None,
|
||||
) -> None:
|
||||
self.entity_id = entity_id
|
||||
self.source_device_id = device_id
|
||||
|
||||
self._attr_name = name
|
||||
# Remove own entity from entities, when it happens to be there. To prevent recursion
|
||||
entities.discard(entity_id)
|
||||
self._entities = entities
|
||||
self._sensor_config = sensor_config
|
||||
if self._is_energy_sensor:
|
||||
self._rounding_digits = int(sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION))
|
||||
self._update_interval: int = int(sensor_config.get(CONF_GROUP_ENERGY_UPDATE_INTERVAL, DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL))
|
||||
else:
|
||||
self._rounding_digits = int(sensor_config.get(CONF_POWER_SENSOR_PRECISION, DEFAULT_POWER_SENSOR_PRECISION))
|
||||
self._update_interval = int(sensor_config.get(CONF_GROUP_POWER_UPDATE_INTERVAL, DEFAULT_GROUP_POWER_UPDATE_INTERVAL))
|
||||
self._attr_suggested_display_precision = self._rounding_digits
|
||||
if unique_id:
|
||||
self._attr_unique_id = unique_id
|
||||
self._native_value_exact = Decimal(0)
|
||||
self._member_states: dict[str, Decimal] = {}
|
||||
self._ignore_unavailable_state = bool(self._sensor_config.get(CONF_IGNORE_UNAVAILABLE_STATE))
|
||||
self._group_type = group_type
|
||||
self._start_time: float = time.time()
|
||||
self._last_update_time: float = 0
|
||||
self._update_interval_exceeded_callback: CALLBACK_TYPE | None = None
|
||||
self._unit_converter_cache: dict[str, Callable[[float], float]] = {}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state listeners."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if self._update_interval > 0:
|
||||
self.async_on_remove(self._cancel_update_interval_exceeded_callback)
|
||||
|
||||
self.async_on_remove(start.async_at_start(self.hass, self.on_start))
|
||||
|
||||
if CONF_HIDE_MEMBERS in self._sensor_config:
|
||||
self._async_hide_members(bool(self._sensor_config.get(CONF_HIDE_MEMBERS)))
|
||||
|
||||
if not self._sensor_config.get(CONF_DISABLE_EXTENDED_ATTRIBUTES, False):
|
||||
self._attr_extra_state_attributes = {
|
||||
ATTR_ENTITIES: self._entities,
|
||||
ATTR_IS_GROUP: True,
|
||||
}
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""
|
||||
This will trigger when entity is about to be removed from HA
|
||||
Unhide the entities, when they where hidden before.
|
||||
"""
|
||||
if self._sensor_config.get(CONF_HIDE_MEMBERS) is True:
|
||||
self._async_hide_members(False)
|
||||
|
||||
@callback
|
||||
def _async_hide_members(self, hide: bool) -> None:
|
||||
"""Hide/unhide group members."""
|
||||
registry = er.async_get(self.hass)
|
||||
for entity_id in self._entities:
|
||||
registry_entry = registry.async_get(entity_id)
|
||||
if not registry_entry:
|
||||
continue
|
||||
|
||||
# We don't want to touch devices which are forced hidden by the user
|
||||
if registry_entry.hidden_by == er.RegistryEntryHider.USER:
|
||||
continue
|
||||
|
||||
hidden_by = er.RegistryEntryHider.INTEGRATION if hide else None
|
||||
registry.async_update_entity(entity_id, hidden_by=hidden_by)
|
||||
|
||||
@callback
|
||||
def on_state_change(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Triggered when one of the group entities changes state."""
|
||||
new_state = event.data.get("new_state")
|
||||
if not new_state: # pragma: no cover
|
||||
return
|
||||
_LOGGER.debug("Group sensor %s. State change for %s: %s", self.entity_id, new_state.entity_id, new_state)
|
||||
calculated_new_state = self.calculate_new_state(new_state)
|
||||
self.set_new_state(calculated_new_state)
|
||||
|
||||
async def init_domain_group(self) -> None:
|
||||
if self._group_type != GroupType.DOMAIN:
|
||||
return
|
||||
domain = self._sensor_config.get(CONF_DOMAIN)
|
||||
if domain == CONF_ALL:
|
||||
entity_registry = er.async_get(self.hass)
|
||||
entities = {entity.entity_id for entity in entity_registry.entities.values() if entity.device_class == self.device_class}
|
||||
else:
|
||||
entities = self.hass.data[DOMAIN].get(DATA_DOMAIN_ENTITIES).get(domain, [])
|
||||
entities = filter_entity_list_by_class(
|
||||
entities,
|
||||
SensorDeviceClass.ENERGY if self._is_energy_sensor else SensorDeviceClass.POWER,
|
||||
)
|
||||
excluded_entities = self._sensor_config.get(CONF_EXCLUDE_ENTITIES) or []
|
||||
self._entities = set({entity for entity in entities if entity not in excluded_entities})
|
||||
|
||||
async def on_start(self, _: Any) -> None: # noqa
|
||||
"""Initialize group sensor when HA is starting."""
|
||||
await self.init_domain_group()
|
||||
|
||||
if not self._entities:
|
||||
_LOGGER.warning("No entities for group sensor %s, setting to unavailable", self.entity_id)
|
||||
self._attr_available = False
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
self._entities,
|
||||
self.on_state_change,
|
||||
),
|
||||
)
|
||||
|
||||
await self.initial_update()
|
||||
|
||||
async def initial_update(self) -> None:
|
||||
"""Initial update for the group sensor state."""
|
||||
all_states = [self.hass.states.get(entity_id) for entity_id in self._entities]
|
||||
states: list[State] = list(filter(None, all_states))
|
||||
available_states = [state for state in states if state and state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]]
|
||||
if not available_states and not self._ignore_unavailable_state:
|
||||
new_state: Decimal | str = STATE_UNAVAILABLE
|
||||
else:
|
||||
new_state = self.calculate_initial_state(available_states, states)
|
||||
self.set_new_state(new_state)
|
||||
|
||||
@callback
|
||||
def set_new_state(self, state: Decimal | str) -> None:
|
||||
"""Set the new state and update the entity."""
|
||||
if state == STATE_UNAVAILABLE or not isinstance(state, Decimal):
|
||||
self._attr_available = self._ignore_unavailable_state
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
self._attr_available = True
|
||||
self._set_native_value(state, write_state=False)
|
||||
|
||||
# Throttled future update pending, return early
|
||||
if self._update_interval_exceeded_callback:
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
if self._should_throttle(current_time):
|
||||
|
||||
@callback
|
||||
def _update_interval_callback(now: datetime) -> None:
|
||||
self._update_interval_exceeded_callback = None
|
||||
self._last_update_time = time.time()
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._update_interval_exceeded_callback = async_call_later(
|
||||
self.hass,
|
||||
self._update_interval,
|
||||
_update_interval_callback,
|
||||
)
|
||||
return
|
||||
|
||||
self._cancel_update_interval_exceeded_callback()
|
||||
self._last_update_time = current_time
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _should_throttle(self, current_time: float) -> bool:
|
||||
if self._update_interval == 0:
|
||||
return False
|
||||
|
||||
# Don't throttle initial updates within first 5 seconds after startup
|
||||
if current_time - self._start_time < 5:
|
||||
return False
|
||||
|
||||
if self._last_update_time == 0:
|
||||
return False # pragma: no cover
|
||||
|
||||
# Apply a minimum throttle of 100ms to prevent flooding during rapid changes
|
||||
if current_time - self._last_update_time < 0.1:
|
||||
return True
|
||||
|
||||
return current_time - self._last_update_time < self._update_interval
|
||||
|
||||
def _cancel_update_interval_exceeded_callback(self) -> None:
|
||||
if self._update_interval_exceeded_callback: # pragma: no cover
|
||||
self._update_interval_exceeded_callback()
|
||||
self._update_interval_exceeded_callback = None
|
||||
|
||||
def _get_state_value_in_native_unit(self, state: State) -> Decimal:
|
||||
"""Convert value of member entity state to match the unit of measurement of the group sensor."""
|
||||
value: str | float = state.state
|
||||
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
if unit and self._attr_native_unit_of_measurement != unit:
|
||||
converter = UNIT_CONVERTERS[unit]
|
||||
value = converter.convert(float(value), unit, self._attr_native_unit_of_measurement)
|
||||
try:
|
||||
return Decimal(value)
|
||||
except DecimalException as err:
|
||||
_LOGGER.warning(
|
||||
"Error converting state value %s to Decimal for %s: %s",
|
||||
value,
|
||||
state.entity_id,
|
||||
err,
|
||||
)
|
||||
return Decimal(0)
|
||||
|
||||
def _set_native_value(self, value: Decimal, write_state: bool = True) -> None:
|
||||
self._native_value_exact = value
|
||||
self._attr_native_value = round(value, self._rounding_digits)
|
||||
if write_state:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def entities(self) -> set[str]:
|
||||
return self._entities
|
||||
|
||||
def get_group_entities(self) -> dict[str, set[str]]:
|
||||
return {ATTR_ENTITIES: self._entities}
|
||||
|
||||
@abstractmethod
|
||||
def calculate_initial_state(
|
||||
self,
|
||||
member_available_states: list[State],
|
||||
member_states: list[State],
|
||||
) -> Decimal | str:
|
||||
"""Implementation for the initial state calculation"""
|
||||
|
||||
@abstractmethod
|
||||
def calculate_new_state(
|
||||
self,
|
||||
state: State,
|
||||
) -> Decimal | str:
|
||||
"""Implementation for the state calculation whenever a member entity changes state"""
|
||||
|
||||
|
||||
class GroupedPowerSensor(GroupedSensor, PowerSensor):
|
||||
"""Grouped power sensor. Sums all values of underlying individual power sensors."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.POWER
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = UnitOfPower.WATT
|
||||
_is_energy_sensor = False
|
||||
|
||||
def calculate_initial_state(
|
||||
self,
|
||||
member_available_states: list[State],
|
||||
member_states: list[State],
|
||||
) -> Decimal | str:
|
||||
self._member_states = {state.entity_id: self._get_state_value_in_native_unit(state) for state in member_available_states}
|
||||
return self.get_summed_state()
|
||||
|
||||
def calculate_new_state(self, state: State) -> Decimal | str:
|
||||
if state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
|
||||
if state.entity_id in self._member_states:
|
||||
del self._member_states[state.entity_id]
|
||||
else:
|
||||
self._member_states[state.entity_id] = self._get_state_value_in_native_unit(state)
|
||||
return self.get_summed_state()
|
||||
|
||||
def get_summed_state(self) -> Decimal | str:
|
||||
if not self._member_states:
|
||||
return Decimal(0) if self._ignore_unavailable_state else STATE_UNAVAILABLE
|
||||
|
||||
return Decimal(sum(self._member_states.values()))
|
||||
|
||||
|
||||
class GroupedEnergySensor(GroupedSensor, RestoreSensor, EnergySensor):
|
||||
"""Grouped energy sensor. Sums all values of underlying individual energy sensors."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.ENERGY
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
_is_energy_sensor = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
name: str,
|
||||
entities: set[str],
|
||||
entity_id: str,
|
||||
sensor_config: dict[str, Any],
|
||||
group_type: GroupType,
|
||||
unique_id: str | None = None,
|
||||
device_id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
name,
|
||||
entities,
|
||||
entity_id,
|
||||
sensor_config,
|
||||
group_type,
|
||||
unique_id,
|
||||
device_id,
|
||||
)
|
||||
|
||||
self._attr_native_unit_of_measurement = ENERGY_UNIT_PREFIX_MAPPING.get(
|
||||
sensor_config.get(CONF_ENERGY_SENSOR_UNIT_PREFIX, UnitPrefix.NONE),
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
)
|
||||
self._prev_state_store: PreviousStateStore = PreviousStateStore(hass)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state listeners."""
|
||||
|
||||
self._prev_state_store = await PreviousStateStore.async_get_instance(self.hass)
|
||||
# Clean up any entities that are no longer part of the group
|
||||
self._prev_state_store.cleanup_entity_states(self.entity_id, self._entities)
|
||||
|
||||
await self.restore_last_state()
|
||||
|
||||
await super().async_added_to_hass()
|
||||
|
||||
async def async_reset(self) -> None:
|
||||
"""Reset the group sensor and underlying member sensor when supported."""
|
||||
_LOGGER.debug("%s: Reset grouped energy sensor", self.entity_id)
|
||||
self._set_native_value(Decimal(0))
|
||||
self.async_write_ha_state()
|
||||
|
||||
for entity_id in self._entities:
|
||||
_LOGGER.debug("Resetting %s", entity_id)
|
||||
await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RESET_ENERGY,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
if self._prev_state_store:
|
||||
self._prev_state_store.set_entity_state(
|
||||
self.entity_id,
|
||||
entity_id,
|
||||
State(entity_id, "0.00"),
|
||||
)
|
||||
|
||||
async def async_calibrate(self, value: str) -> None:
|
||||
_LOGGER.debug("%s: Calibrate group energy sensor to: %s", self.entity_id, value)
|
||||
self._set_native_value(Decimal(value))
|
||||
self.async_write_ha_state()
|
||||
|
||||
def calculate_initial_state(
|
||||
self,
|
||||
member_available_states: list[State],
|
||||
member_states: list[State],
|
||||
) -> Decimal:
|
||||
"""Calculate the new group energy sensor state
|
||||
For each member sensor we calculate the delta by looking at the previous known state and compare it to the current.
|
||||
"""
|
||||
group_sum = Decimal(self._native_value_exact) if self._native_value_exact else Decimal(0)
|
||||
_LOGGER.debug("%s: Recalculate, current value: %s", self.entity_id, group_sum)
|
||||
for state in member_available_states:
|
||||
group_sum += self.calculate_delta(state)
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: New value: %s",
|
||||
self.entity_id,
|
||||
round(group_sum, self._rounding_digits),
|
||||
)
|
||||
return group_sum
|
||||
|
||||
def calculate_new_state(self, state: State) -> Decimal | str:
|
||||
group_sum = Decimal(self._native_value_exact) if self._native_value_exact else Decimal(0)
|
||||
if state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
|
||||
if group_sum == 0:
|
||||
return STATE_UNAVAILABLE
|
||||
_LOGGER.debug(
|
||||
"skipping state for %s, sensor unavailable or unknown",
|
||||
state.entity_id,
|
||||
)
|
||||
return group_sum
|
||||
|
||||
_LOGGER.debug("%s: Recalculate, current value: %s", self.entity_id, group_sum)
|
||||
|
||||
group_sum += self.calculate_delta(state)
|
||||
_LOGGER.debug(
|
||||
"%s: New value: %s",
|
||||
self.entity_id,
|
||||
round(group_sum, self._rounding_digits),
|
||||
)
|
||||
return group_sum
|
||||
|
||||
def calculate_delta(self, state: State) -> Decimal:
|
||||
"""Calculate the delta between the current and previous state."""
|
||||
prev_state = self._prev_state_store.get_entity_state(
|
||||
self.entity_id,
|
||||
state.entity_id,
|
||||
)
|
||||
|
||||
cur_value = self._get_state_value_in_native_unit(state)
|
||||
prev_value = self._get_state_value_in_native_unit(prev_state) if prev_state is not None else Decimal(0)
|
||||
|
||||
# Always store current state as the new "previous" state
|
||||
self._prev_state_store.set_entity_state(
|
||||
self.entity_id,
|
||||
state.entity_id,
|
||||
state,
|
||||
)
|
||||
|
||||
start_at_zero = self._sensor_config.get(CONF_GROUP_ENERGY_START_AT_ZERO, True)
|
||||
if prev_state is None and start_at_zero: # noqa: SIM108
|
||||
delta = Decimal(0)
|
||||
else:
|
||||
delta = cur_value - prev_value
|
||||
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG): # pragma: no cover
|
||||
_LOGGER.debug(
|
||||
"delta for entity %s: %s, prev=%s, cur=%s",
|
||||
state.entity_id,
|
||||
round(delta, self._rounding_digits),
|
||||
round(prev_value, self._rounding_digits),
|
||||
round(cur_value, self._rounding_digits),
|
||||
)
|
||||
|
||||
if delta < 0:
|
||||
_LOGGER.warning(
|
||||
"skipping state for %s, probably erroneous value or sensor was reset",
|
||||
state.entity_id,
|
||||
)
|
||||
return Decimal(0)
|
||||
|
||||
return delta
|
||||
|
||||
async def restore_last_state(self) -> None:
|
||||
"""Restore the last known state of the group sensor."""
|
||||
last_state = await self.async_get_last_state()
|
||||
if last_state and last_state.state in [None, STATE_UNKNOWN, STATE_UNAVAILABLE]:
|
||||
return
|
||||
|
||||
last_sensor_state = await self.async_get_last_sensor_data()
|
||||
try:
|
||||
if last_sensor_state and last_sensor_state.native_value:
|
||||
self._set_native_value(Decimal(last_sensor_state.native_value)) # type: ignore
|
||||
elif last_state:
|
||||
self._set_native_value(Decimal(last_state.state))
|
||||
_LOGGER.debug(
|
||||
"%s: Restoring state: %s",
|
||||
self.entity_id,
|
||||
self._attr_native_value,
|
||||
)
|
||||
except DecimalException as err:
|
||||
_LOGGER.warning(
|
||||
"%s: Could not restore last state: %s",
|
||||
self.entity_id,
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
class PreviousStateStore:
|
||||
@staticmethod
|
||||
@singleton("powercalc_group_storage")
|
||||
async def async_get_instance(hass: HomeAssistant) -> PreviousStateStore:
|
||||
"""Get the singleton instance of this data helper."""
|
||||
instance = PreviousStateStore(hass)
|
||||
instance.states = {}
|
||||
|
||||
try:
|
||||
_LOGGER.debug("Load previous energy sensor states from store")
|
||||
stored_states = await instance.store.async_load() or {}
|
||||
for group, entities in stored_states.items():
|
||||
instance.states[group] = {entity_id: State.from_dict(json_state) for (entity_id, json_state) in entities.items()}
|
||||
except HomeAssistantError as exc: # pragma: no cover
|
||||
_LOGGER.error("Error loading previous energy sensor states", exc_info=exc)
|
||||
|
||||
instance.async_setup_dump()
|
||||
|
||||
return instance
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
self.store: Store = PreviousStateStoreStore(
|
||||
hass,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_KEY,
|
||||
encoder=JSONEncoder,
|
||||
)
|
||||
self.states: dict[str, dict[str, State | None]] = {}
|
||||
self.hass = hass
|
||||
|
||||
def get_entity_state(self, group: str, entity_id: str) -> State | None:
|
||||
group_states = self.states.get(group)
|
||||
if group_states is None:
|
||||
return None
|
||||
return group_states.get(entity_id)
|
||||
|
||||
def set_entity_state(self, group: str, entity_id: str, state: State) -> None:
|
||||
"""Set the state for an energy sensor."""
|
||||
self.states.setdefault(group, {})[entity_id] = state
|
||||
|
||||
def cleanup_entity_states(self, group: str, current_entities: set[str]) -> None:
|
||||
"""Remove entity states that are no longer part of the group."""
|
||||
group_states = self.states.get(group)
|
||||
if group_states is None:
|
||||
return
|
||||
|
||||
# Find entities that are in the store but not in the current set
|
||||
entities_to_remove = set(group_states.keys()) - current_entities
|
||||
|
||||
# Remove those entities from the store
|
||||
for entity_id in entities_to_remove:
|
||||
_LOGGER.debug("Removing entity %s from group %s in PreviousStateStore", entity_id, group)
|
||||
group_states.pop(entity_id, None)
|
||||
|
||||
async def persist_states(self) -> None:
|
||||
"""Save the current states to storage."""
|
||||
try:
|
||||
await self.store.async_save(self.states)
|
||||
except HomeAssistantError as exc: # pragma: no cover
|
||||
_LOGGER.error("Error saving current states", exc_info=exc)
|
||||
|
||||
@callback
|
||||
def async_setup_dump(self) -> None:
|
||||
"""Set up the listeners for persistence."""
|
||||
|
||||
async def _async_dump_states(*_: Any) -> None: # noqa: ANN401
|
||||
await self.persist_states()
|
||||
|
||||
# Dump states periodically
|
||||
cancel_interval = async_track_time_interval(
|
||||
self.hass,
|
||||
_async_dump_states,
|
||||
STATE_DUMP_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_dump_states_at_stop(*_: Any) -> None: # noqa: ANN401
|
||||
cancel_interval()
|
||||
await self.persist_states()
|
||||
|
||||
# Dump states when stopping hass
|
||||
self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
_async_dump_states_at_stop,
|
||||
)
|
||||
|
||||
|
||||
class PreviousStateStoreStore(Store):
|
||||
"""Store area registry data."""
|
||||
|
||||
async def _async_migrate_func( # type: ignore
|
||||
self,
|
||||
old_major_version: int,
|
||||
old_minor_version: int,
|
||||
old_data: dict[str, list[dict[str, Any]]],
|
||||
) -> dict[str, Any]:
|
||||
"""Migrate to the new version."""
|
||||
if old_major_version == 1:
|
||||
return {}
|
||||
return old_data # pragma: no cover
|
||||
@@ -0,0 +1,30 @@
|
||||
from homeassistant.const import CONF_DOMAIN, CONF_NAME, CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.const import CONF_GROUP_TYPE, GroupType
|
||||
from custom_components.powercalc.sensors.group.custom import create_group_sensors_custom
|
||||
|
||||
|
||||
async def create_domain_group_sensor(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
) -> list[Entity]:
|
||||
domain = config[CONF_DOMAIN]
|
||||
name: str = config.get(CONF_NAME, f"All {domain}")
|
||||
if CONF_UNIQUE_ID not in config:
|
||||
config[CONF_UNIQUE_ID] = generate_unique_id(config)
|
||||
config[CONF_GROUP_TYPE] = GroupType.DOMAIN
|
||||
return await create_group_sensors_custom(
|
||||
hass,
|
||||
name,
|
||||
config,
|
||||
set(),
|
||||
set(),
|
||||
force_create=True,
|
||||
)
|
||||
|
||||
|
||||
def generate_unique_id(sensor_config: ConfigType) -> str:
|
||||
return f"powercalc_domaingroup_{sensor_config[CONF_DOMAIN]}"
|
||||
@@ -0,0 +1,57 @@
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.analytics.analytics import collect_analytics
|
||||
from custom_components.powercalc.const import CONF_GROUP_TYPE, DATA_GROUP_TYPES, GroupType
|
||||
from custom_components.powercalc.errors import SensorConfigurationError
|
||||
import custom_components.powercalc.sensors.group.custom as custom_group
|
||||
import custom_components.powercalc.sensors.group.domain as domain_group
|
||||
import custom_components.powercalc.sensors.group.standby as standby_group
|
||||
import custom_components.powercalc.sensors.group.subtract as subtract_group
|
||||
from custom_components.powercalc.sensors.group.tracked_untracked import TrackedPowerSensorFactory
|
||||
|
||||
|
||||
async def create_group_sensors(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
config_entry: ConfigEntry | None,
|
||||
entities: list[Entity] | None = None,
|
||||
) -> list[Entity]:
|
||||
"""Create group sensors for a given sensor configuration."""
|
||||
group_type: GroupType = GroupType(sensor_config.get(CONF_GROUP_TYPE, GroupType.CUSTOM))
|
||||
collect_analytics(hass, config_entry).inc(DATA_GROUP_TYPES, group_type)
|
||||
|
||||
if group_type == GroupType.DOMAIN:
|
||||
return await domain_group.create_domain_group_sensor(
|
||||
hass,
|
||||
sensor_config,
|
||||
)
|
||||
if group_type == GroupType.STANDBY:
|
||||
return await standby_group.create_general_standby_sensors(hass, sensor_config)
|
||||
|
||||
if group_type == GroupType.CUSTOM:
|
||||
if config_entry:
|
||||
return await custom_group.create_group_sensors_gui(
|
||||
hass=hass,
|
||||
entry=config_entry,
|
||||
sensor_config=sensor_config,
|
||||
)
|
||||
return await custom_group.create_group_sensors_yaml(
|
||||
hass=hass,
|
||||
sensor_config=sensor_config,
|
||||
entities=entities or [],
|
||||
)
|
||||
|
||||
if group_type == GroupType.SUBTRACT:
|
||||
return await subtract_group.create_subtract_group_sensors(
|
||||
hass=hass,
|
||||
config=sensor_config,
|
||||
)
|
||||
|
||||
if group_type == GroupType.TRACKED_UNTRACKED and config_entry:
|
||||
factory = TrackedPowerSensorFactory(hass, config_entry, sensor_config)
|
||||
return await factory.create_tracked_untracked_group_sensors()
|
||||
|
||||
raise SensorConfigurationError(f"Group type {group_type} invalid") # pragma: no cover
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.common import create_source_entity
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_CREATE_ENERGY_SENSORS,
|
||||
CONF_POWER_SENSOR_PRECISION,
|
||||
DATA_STANDBY_POWER_SENSORS,
|
||||
DEFAULT_POWER_SENSOR_PRECISION,
|
||||
DOMAIN,
|
||||
DUMMY_ENTITY_ID,
|
||||
SIGNAL_POWER_SENSOR_STATE_CHANGE,
|
||||
)
|
||||
from custom_components.powercalc.sensors.energy import create_energy_sensor
|
||||
from custom_components.powercalc.sensors.power import PowerSensor
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_general_standby_sensors(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
) -> list[Entity]:
|
||||
sensors: list[Entity] = []
|
||||
power_sensor = StandbyPowerSensor(
|
||||
hass,
|
||||
rounding_digits=int(config.get(CONF_POWER_SENSOR_PRECISION, DEFAULT_POWER_SENSOR_PRECISION)),
|
||||
)
|
||||
sensors.append(power_sensor)
|
||||
if config.get(CONF_CREATE_ENERGY_SENSORS):
|
||||
power_sensor.entity_id = "sensor.all_standby_power"
|
||||
sensor_config = config.copy()
|
||||
sensor_config[CONF_NAME] = "All standby"
|
||||
source_entity = await create_source_entity(DUMMY_ENTITY_ID, hass)
|
||||
energy_sensor = await create_energy_sensor(
|
||||
hass,
|
||||
sensor_config,
|
||||
power_sensor,
|
||||
source_entity,
|
||||
)
|
||||
sensors.append(energy_sensor)
|
||||
return sensors
|
||||
|
||||
|
||||
class StandbyPowerSensor(SensorEntity, PowerSensor):
|
||||
_attr_device_class = SensorDeviceClass.POWER
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = UnitOfPower.WATT
|
||||
_attr_has_entity_name = True
|
||||
_attr_unique_id = "powercalc_standby_group"
|
||||
_attr_name = "All standby power"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, rounding_digits: int = 2) -> None:
|
||||
self.standby_sensors: dict[str, Decimal] = hass.data[DOMAIN][DATA_STANDBY_POWER_SENSORS]
|
||||
self._rounding_digits = rounding_digits
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state listeners."""
|
||||
await super().async_added_to_hass()
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_POWER_SENSOR_STATE_CHANGE,
|
||||
self._recalculate,
|
||||
)
|
||||
|
||||
async def _recalculate(self) -> None:
|
||||
"""Calculate sum of all power sensors in standby, and update the state of the sensor."""
|
||||
if self.standby_sensors:
|
||||
self._attr_native_value = Decimal(
|
||||
round( # type: ignore
|
||||
sum(self.standby_sensors.values()),
|
||||
self._rounding_digits,
|
||||
),
|
||||
)
|
||||
else:
|
||||
self._attr_native_value = None
|
||||
self.async_schedule_update_ha_state(True)
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_CREATE_ENERGY_SENSORS,
|
||||
CONF_SUBTRACT_ENTITIES,
|
||||
CONF_UTILITY_METER_NET_CONSUMPTION,
|
||||
GroupType,
|
||||
)
|
||||
from custom_components.powercalc.errors import SensorConfigurationError
|
||||
from custom_components.powercalc.sensors.abstract import generate_power_sensor_entity_id, generate_power_sensor_name
|
||||
from custom_components.powercalc.sensors.energy import create_energy_sensor
|
||||
from custom_components.powercalc.sensors.group.custom import GroupedPowerSensor
|
||||
from custom_components.powercalc.sensors.utility_meter import create_utility_meters
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_subtract_group_sensors(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
) -> list[Entity]:
|
||||
"""Create subtract group sensors."""
|
||||
|
||||
validate_config(config)
|
||||
group_name = str(config.get(CONF_NAME))
|
||||
base_entity_id = str(config.get(CONF_ENTITY_ID))
|
||||
subtract_entities = cast(list, config.get(CONF_SUBTRACT_ENTITIES))
|
||||
|
||||
name = generate_power_sensor_name(config, group_name)
|
||||
unique_id = config.get(CONF_UNIQUE_ID, generate_unique_id(config))
|
||||
entity_id = generate_power_sensor_entity_id(
|
||||
hass,
|
||||
config,
|
||||
name=group_name,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Creating grouped power sensor: %s (entity_id=%s)", name, entity_id)
|
||||
|
||||
sensors: list[Entity] = []
|
||||
power_sensor = SubtractGroupSensor(
|
||||
hass,
|
||||
name,
|
||||
config,
|
||||
entity_id,
|
||||
base_entity_id,
|
||||
subtract_entities,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
sensors.append(power_sensor)
|
||||
if config.get(CONF_CREATE_ENERGY_SENSORS):
|
||||
energy_sensor = await create_energy_sensor(
|
||||
hass,
|
||||
config,
|
||||
power_sensor,
|
||||
)
|
||||
sensors.append(energy_sensor)
|
||||
|
||||
config[CONF_UTILITY_METER_NET_CONSUMPTION] = True
|
||||
sensors.extend(
|
||||
await create_utility_meters(
|
||||
hass,
|
||||
energy_sensor,
|
||||
config,
|
||||
),
|
||||
)
|
||||
return sensors
|
||||
|
||||
|
||||
def generate_unique_id(sensor_config: ConfigType) -> str:
|
||||
"""Generate unique_id for subtract group sensor."""
|
||||
base_entity_id = str(sensor_config[CONF_ENTITY_ID])
|
||||
return f"pc_subtract_{base_entity_id}"
|
||||
|
||||
|
||||
def validate_config(config: ConfigType) -> None:
|
||||
"""Validate subtract group sensor configuration."""
|
||||
if CONF_NAME not in config:
|
||||
raise SensorConfigurationError("name is required")
|
||||
|
||||
if CONF_ENTITY_ID not in config:
|
||||
raise SensorConfigurationError("entity_id is required")
|
||||
|
||||
if CONF_SUBTRACT_ENTITIES not in config:
|
||||
raise SensorConfigurationError("subtract_entities is required")
|
||||
|
||||
|
||||
class SubtractGroupSensor(GroupedPowerSensor):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
name: str,
|
||||
sensor_config: ConfigType,
|
||||
entity_id: str,
|
||||
base_entity_id: str,
|
||||
subtract_entities: list[str],
|
||||
unique_id: str | None = None,
|
||||
) -> None:
|
||||
all_entities = {base_entity_id, *subtract_entities}
|
||||
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
name=name,
|
||||
entities=all_entities,
|
||||
entity_id=entity_id,
|
||||
sensor_config=sensor_config,
|
||||
group_type=GroupType.SUBTRACT,
|
||||
unique_id=unique_id,
|
||||
device_id=None,
|
||||
)
|
||||
|
||||
self._base_entity_id = base_entity_id
|
||||
self._subtract_entities = subtract_entities
|
||||
|
||||
def get_summed_state(self) -> Decimal | str:
|
||||
base_value = self._member_states.get(self._base_entity_id)
|
||||
if base_value is None:
|
||||
return STATE_UNAVAILABLE
|
||||
subtracted_value = base_value
|
||||
for entity_id in self._subtract_entities:
|
||||
subtracted_value -= self._member_states.get(entity_id, 0)
|
||||
return subtracted_value
|
||||
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import Entity
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED, EventEntityRegistryUpdatedData
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_CREATE_ENERGY_SENSOR,
|
||||
CONF_DISABLE_EXTENDED_ATTRIBUTES,
|
||||
CONF_ENERGY_SENSOR_UNIT_PREFIX,
|
||||
CONF_EXCLUDE_ENTITIES,
|
||||
CONF_GROUP_TRACKED_AUTO,
|
||||
CONF_GROUP_TRACKED_POWER_ENTITIES,
|
||||
CONF_MAIN_POWER_SENSOR,
|
||||
CONF_UTILITY_METER_NET_CONSUMPTION,
|
||||
GroupType,
|
||||
UnitPrefix,
|
||||
)
|
||||
from custom_components.powercalc.group_include.filter import LambdaFilter
|
||||
from custom_components.powercalc.group_include.include import find_entities
|
||||
from custom_components.powercalc.sensors.abstract import (
|
||||
generate_energy_sensor_entity_id,
|
||||
generate_energy_sensor_name,
|
||||
generate_power_sensor_entity_id,
|
||||
generate_power_sensor_name,
|
||||
)
|
||||
from custom_components.powercalc.sensors.energy import VirtualEnergySensor
|
||||
from custom_components.powercalc.sensors.group.custom import GroupedPowerSensor, GroupedSensor
|
||||
from custom_components.powercalc.sensors.group.subtract import SubtractGroupSensor
|
||||
from custom_components.powercalc.sensors.power import PowerSensor
|
||||
from custom_components.powercalc.sensors.utility_meter import create_utility_meters
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SensorType(StrEnum):
|
||||
TRACKED = "tracked"
|
||||
UNTRACKED = "untracked"
|
||||
|
||||
|
||||
async def find_auto_tracked_power_entities(hass: HomeAssistant, exclude_entities: set[str] | None = None) -> set[str]:
|
||||
"""Find tracked power entities."""
|
||||
entity_filter = None
|
||||
if exclude_entities:
|
||||
entity_filter = LambdaFilter(lambda entity: entity.entity_id not in exclude_entities)
|
||||
result = await find_entities(hass, entity_filter)
|
||||
return {entity.entity_id for entity in result.resolved if isinstance(entity, PowerSensor) and not isinstance(entity, GroupedSensor)}
|
||||
|
||||
|
||||
class TrackedPowerSensorFactory:
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, config: ConfigType) -> None:
|
||||
self.hass = hass
|
||||
self.tracked_entities: set[str] = set()
|
||||
self.config_entry = config_entry
|
||||
self.config = config
|
||||
|
||||
async def create_tracked_untracked_group_sensors(self) -> list[Entity]:
|
||||
"""Create tracked/untracked group sensors."""
|
||||
|
||||
unique_id = str(self.config.get(CONF_UNIQUE_ID))
|
||||
main_power_sensor = str(self.config.get(CONF_MAIN_POWER_SENSOR)) if self.config.get(CONF_MAIN_POWER_SENSOR) else None
|
||||
self.config[CONF_DISABLE_EXTENDED_ATTRIBUTES] = True # prevent adding all entities in the state attributes
|
||||
|
||||
self.tracked_entities = await self.get_tracked_power_entities()
|
||||
if main_power_sensor and main_power_sensor in self.tracked_entities:
|
||||
self.tracked_entities.remove(main_power_sensor)
|
||||
|
||||
should_create_energy_sensor = bool(self.config.get(CONF_CREATE_ENERGY_SENSOR, False))
|
||||
|
||||
entities: list[Entity] = []
|
||||
tracked_sensor = await self.create_tracked_power_sensor(SensorType.TRACKED, unique_id, self.tracked_entities)
|
||||
entities.append(tracked_sensor)
|
||||
if should_create_energy_sensor:
|
||||
energy_sensor = await self.create_energy_sensor(SensorType.TRACKED, tracked_sensor)
|
||||
entities.append(energy_sensor)
|
||||
entities.extend(
|
||||
await create_utility_meters(
|
||||
self.hass,
|
||||
energy_sensor,
|
||||
{CONF_UTILITY_METER_NET_CONSUMPTION: True, **self.config},
|
||||
),
|
||||
)
|
||||
|
||||
if main_power_sensor:
|
||||
untracked_sensor = await self.create_untracked_power_sensor(
|
||||
SensorType.UNTRACKED,
|
||||
unique_id,
|
||||
main_power_sensor,
|
||||
tracked_sensor.entity_id,
|
||||
)
|
||||
entities.append(untracked_sensor)
|
||||
if should_create_energy_sensor:
|
||||
energy_sensor = await self.create_energy_sensor(SensorType.UNTRACKED, untracked_sensor)
|
||||
entities.append(energy_sensor)
|
||||
entities.extend(
|
||||
await create_utility_meters(
|
||||
self.hass,
|
||||
energy_sensor,
|
||||
{CONF_UTILITY_METER_NET_CONSUMPTION: True, **self.config},
|
||||
),
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
async def get_tracked_power_entities(self) -> set[str]:
|
||||
"""
|
||||
Get all power entities which are part of the tracked sensor group
|
||||
"""
|
||||
if not bool(self.config.get(CONF_GROUP_TRACKED_AUTO, False)):
|
||||
return set(self.config.get(CONF_GROUP_TRACKED_POWER_ENTITIES)) # type: ignore
|
||||
|
||||
# For auto mode, we also want to listen for any changes in the entity registry
|
||||
# Dynamically add/remove power sensors from the tracked group
|
||||
@callback
|
||||
def _start_entity_registry_listener(_: Any) -> None: # noqa ANN401
|
||||
self.hass.bus.async_listen(EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated)
|
||||
|
||||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _start_entity_registry_listener)
|
||||
|
||||
exclude_entities = self.config.get(CONF_EXCLUDE_ENTITIES)
|
||||
return await find_auto_tracked_power_entities(self.hass, set(exclude_entities) if exclude_entities else None)
|
||||
|
||||
async def _handle_entity_registry_updated(
|
||||
self,
|
||||
event: Event[EventEntityRegistryUpdatedData],
|
||||
) -> None:
|
||||
"""Listen to all entity registry updates and reload the config entry if a power sensor is added/removed."""
|
||||
entity_id = event.data["entity_id"]
|
||||
action = event.data["action"]
|
||||
|
||||
if action == "update" and "old_entity_id" in event.data:
|
||||
if event.data["old_entity_id"] in self.tracked_entities: # type: ignore
|
||||
return await self.reload()
|
||||
return None # pragma: no cover
|
||||
|
||||
if action == "remove" and entity_id in self.tracked_entities:
|
||||
return await self.reload()
|
||||
|
||||
if action == "create":
|
||||
registry = er.async_get(self.hass)
|
||||
entity_entry = registry.async_get(entity_id)
|
||||
if entity_entry and entity_entry.original_device_class == SensorDeviceClass.POWER:
|
||||
return await self.reload()
|
||||
return None
|
||||
|
||||
async def reload(self) -> None:
|
||||
"""Reload the config entry."""
|
||||
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
||||
|
||||
async def create_tracked_power_sensor(
|
||||
self,
|
||||
sensor_type: SensorType,
|
||||
unique_id: str,
|
||||
tracked_entities: set[str],
|
||||
) -> GroupedPowerSensor:
|
||||
_LOGGER.debug("Creating tracked grouped power sensor, entities: %s", tracked_entities)
|
||||
unique_id = f"{unique_id}_{sensor_type}_power"
|
||||
entity_id = generate_power_sensor_entity_id(self.hass, self.config, name=sensor_type, unique_id=unique_id)
|
||||
name = generate_power_sensor_name(self.config, name=sensor_type)
|
||||
return GroupedPowerSensor(
|
||||
self.hass,
|
||||
sensor_config=self.config,
|
||||
group_type=GroupType.TRACKED_UNTRACKED,
|
||||
entities=tracked_entities,
|
||||
entity_id=entity_id,
|
||||
name=name,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
async def create_untracked_power_sensor(
|
||||
self,
|
||||
sensor_type: SensorType,
|
||||
unique_id: str,
|
||||
main_power_entity_id: str,
|
||||
tracked_entity_id: str,
|
||||
) -> GroupedPowerSensor:
|
||||
_LOGGER.debug("Creating untracked grouped power sensor")
|
||||
unique_id = f"{unique_id}_{sensor_type}_power"
|
||||
entity_id = generate_power_sensor_entity_id(self.hass, self.config, name=sensor_type, unique_id=unique_id)
|
||||
name = generate_power_sensor_name(self.config, name=sensor_type)
|
||||
return SubtractGroupSensor(
|
||||
self.hass,
|
||||
entity_id=entity_id,
|
||||
name=name,
|
||||
sensor_config=self.config,
|
||||
base_entity_id=main_power_entity_id,
|
||||
subtract_entities=[tracked_entity_id],
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
async def create_energy_sensor(
|
||||
self,
|
||||
sensor_type: SensorType,
|
||||
power_sensor: GroupedPowerSensor,
|
||||
) -> VirtualEnergySensor:
|
||||
"""Create an energy sensor for a power sensor."""
|
||||
_LOGGER.debug("Creating %s grouped energy sensor", sensor_type)
|
||||
unique_id = f"{power_sensor.unique_id}_{sensor_type}_energy"
|
||||
name = generate_energy_sensor_name(self.config, sensor_type)
|
||||
entity_id = generate_energy_sensor_entity_id(self.hass, self.config, name=sensor_type, unique_id=unique_id)
|
||||
return VirtualEnergySensor(
|
||||
hass=self.hass,
|
||||
source_entity=power_sensor.entity_id,
|
||||
entity_id=entity_id,
|
||||
name=name,
|
||||
unique_id=unique_id,
|
||||
sensor_config=self.config,
|
||||
unit_prefix=self.config.get(CONF_ENERGY_SENSOR_UNIT_PREFIX, UnitPrefix.KILO),
|
||||
)
|
||||
@@ -0,0 +1,828 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
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,
|
||||
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_DISCOVERY_MANAGER,
|
||||
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,
|
||||
CalculationStrategy,
|
||||
PowerProfileSource,
|
||||
)
|
||||
from custom_components.powercalc.discovery import DiscoveryManager
|
||||
from custom_components.powercalc.errors import (
|
||||
ModelNotSupportedError,
|
||||
StrategyConfigurationError,
|
||||
UnsupportedStrategyError,
|
||||
)
|
||||
from custom_components.powercalc.helpers import evaluate_power
|
||||
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 .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: dict,
|
||||
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 await 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: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER]
|
||||
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
|
||||
|
||||
|
||||
async def create_real_power_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: dict,
|
||||
) -> 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(SensorEntity, PowerSensor):
|
||||
"""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: dict,
|
||||
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: dict = 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(*_: Any) -> None: # noqa: ANN401
|
||||
"""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 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.
|
||||
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._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)
|
||||
|
||||
# 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)
|
||||
|
||||
force_update_interval = self._sensor_config.get(CONF_POWER_UPDATE_INTERVAL, 0)
|
||||
if force_update_interval > 0:
|
||||
|
||||
@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))
|
||||
|
||||
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 [STATE_UNAVAILABLE, STATE_UNKNOWN]
|
||||
|
||||
async def calculate_power(self, state: State) -> Decimal | None:
|
||||
"""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
|
||||
):
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
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
|
||||
if self._multiply_factor_standby and self._multiply_factor:
|
||||
additional_standby_power *= Decimal(self._multiply_factor)
|
||||
power += additional_standby_power
|
||||
|
||||
return Decimal(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(*_: Any) -> None: # noqa: ANN401
|
||||
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,
|
||||
_update_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 = await evaluate_power(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 HomeAssistantError("supported only playbook enabled sensors")
|
||||
return self._strategy_instance
|
||||
|
||||
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 HomeAssistantError(
|
||||
"This is only supported for sensors having sub profiles, and no automatic profile selection",
|
||||
)
|
||||
|
||||
known_profiles = [profile[0] for profile in await self._power_profile.get_sub_profiles()]
|
||||
if profile not in known_profiles:
|
||||
raise HomeAssistantError(f"{profile} is not a possible sub profile")
|
||||
|
||||
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
|
||||
@@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import inspect
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.utility_meter import DEFAULT_OFFSET
|
||||
from homeassistant.components.utility_meter.const import (
|
||||
DATA_TARIFF_SENSORS,
|
||||
DATA_UTILITY,
|
||||
)
|
||||
from homeassistant.components.utility_meter.select import TariffSelect
|
||||
from homeassistant.components.utility_meter.sensor import UtilityMeterSensor
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import async_generate_entity_id
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_CREATE_UTILITY_METERS,
|
||||
CONF_ENERGY_SENSOR_PRECISION,
|
||||
CONF_IGNORE_UNAVAILABLE_STATE,
|
||||
CONF_UTILITY_METER_NET_CONSUMPTION,
|
||||
CONF_UTILITY_METER_OFFSET,
|
||||
CONF_UTILITY_METER_TARIFFS,
|
||||
CONF_UTILITY_METER_TYPES,
|
||||
DEFAULT_ENERGY_SENSOR_PRECISION,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.powercalc.select import DATA_PENDING_SELECT_ENTITIES, SIGNAL_CREATE_SELECT_ENTITIES
|
||||
|
||||
from .abstract import BaseEntity
|
||||
from .energy import EnergySensor, RealEnergySensor
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
GENERAL_TARIFF = "general"
|
||||
|
||||
|
||||
async def create_utility_meters(
|
||||
hass: HomeAssistant,
|
||||
energy_sensor: EnergySensor,
|
||||
sensor_config: dict,
|
||||
config_entry: ConfigEntry | None = None,
|
||||
) -> list[VirtualUtilityMeter]:
|
||||
"""Create the utility meters."""
|
||||
if not sensor_config.get(CONF_CREATE_UTILITY_METERS):
|
||||
return []
|
||||
|
||||
if DATA_UTILITY not in hass.data: # pragma: no cover
|
||||
hass.data[DATA_UTILITY] = {}
|
||||
|
||||
tariffs = list(sensor_config.get(CONF_UTILITY_METER_TARIFFS, []))
|
||||
meter_types = list(sensor_config.get(CONF_UTILITY_METER_TYPES, []))
|
||||
|
||||
utility_meters = []
|
||||
for meter_type in meter_types:
|
||||
unique_id = f"{energy_sensor.unique_id}_{meter_type}" if energy_sensor.unique_id else None
|
||||
if should_create_utility_meter(hass, unique_id, energy_sensor):
|
||||
utility_meters.extend(
|
||||
await create_meters_for_type(
|
||||
hass,
|
||||
energy_sensor,
|
||||
sensor_config,
|
||||
config_entry,
|
||||
unique_id,
|
||||
meter_type,
|
||||
tariffs,
|
||||
),
|
||||
)
|
||||
|
||||
return utility_meters
|
||||
|
||||
|
||||
def should_create_utility_meter(
|
||||
hass: HomeAssistant,
|
||||
unique_id: str | None,
|
||||
energy_sensor: EnergySensor,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a utility meter should be created.
|
||||
Prevent duplicate creation of utility meter. See #1322
|
||||
"""
|
||||
|
||||
if not isinstance(energy_sensor, RealEnergySensor) or not unique_id:
|
||||
return True
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
existing_entity_id = entity_registry.async_get_entity_id(
|
||||
domain=SENSOR_DOMAIN,
|
||||
platform=DOMAIN,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
return not (existing_entity_id and hass.states.get(existing_entity_id)) # pragma: no cover
|
||||
|
||||
|
||||
async def create_meters_for_type(
|
||||
hass: HomeAssistant,
|
||||
energy_sensor: EnergySensor,
|
||||
sensor_config: dict,
|
||||
config_entry: ConfigEntry | None,
|
||||
unique_id: str | None,
|
||||
meter_type: str,
|
||||
tariffs: list[str],
|
||||
) -> list[VirtualUtilityMeter]:
|
||||
"""Create meters for a specific meter type."""
|
||||
name = f"{energy_sensor.name} {meter_type}"
|
||||
entity_id = f"{energy_sensor.entity_id}_{slugify(meter_type)}"
|
||||
|
||||
tariff_sensors = []
|
||||
utility_meters = []
|
||||
|
||||
# Create generic utility meter
|
||||
if not tariffs or GENERAL_TARIFF in tariffs:
|
||||
utility_meter = await create_utility_meter(
|
||||
hass,
|
||||
energy_sensor.entity_id,
|
||||
entity_id,
|
||||
name,
|
||||
sensor_config,
|
||||
meter_type,
|
||||
unique_id,
|
||||
)
|
||||
tariff_sensors.append(utility_meter)
|
||||
utility_meters.append(utility_meter)
|
||||
|
||||
# Create tariff-specific utility meters
|
||||
if tariffs:
|
||||
new_tariff_sensors = await create_tariff_meters(
|
||||
hass,
|
||||
energy_sensor,
|
||||
entity_id,
|
||||
name,
|
||||
sensor_config,
|
||||
config_entry,
|
||||
meter_type,
|
||||
unique_id,
|
||||
tariffs,
|
||||
)
|
||||
tariff_sensors.extend(new_tariff_sensors)
|
||||
utility_meters.extend(new_tariff_sensors)
|
||||
|
||||
hass.data[DATA_UTILITY][entity_id] = {DATA_TARIFF_SENSORS: tariff_sensors}
|
||||
return utility_meters
|
||||
|
||||
|
||||
async def create_tariff_meters(
|
||||
hass: HomeAssistant,
|
||||
energy_sensor: EnergySensor,
|
||||
entity_id: str,
|
||||
name: str,
|
||||
sensor_config: dict,
|
||||
config_entry: ConfigEntry | None,
|
||||
meter_type: str,
|
||||
unique_id: str | None,
|
||||
tariffs: list[str],
|
||||
) -> list[VirtualUtilityMeter]:
|
||||
"""Create utility meters for specific tariffs."""
|
||||
filtered_tariffs = [t for t in tariffs if t != GENERAL_TARIFF]
|
||||
tariff_select = await create_tariff_select(config_entry, filtered_tariffs, hass, name, unique_id)
|
||||
|
||||
tariff_sensors = []
|
||||
for tariff in filtered_tariffs:
|
||||
utility_meter = await create_utility_meter(
|
||||
hass,
|
||||
energy_sensor.entity_id,
|
||||
entity_id,
|
||||
name,
|
||||
sensor_config,
|
||||
meter_type,
|
||||
unique_id,
|
||||
tariff,
|
||||
tariff_select.entity_id,
|
||||
)
|
||||
tariff_sensors.append(utility_meter)
|
||||
|
||||
return tariff_sensors
|
||||
|
||||
|
||||
async def create_tariff_select(
|
||||
config_entry: ConfigEntry | None,
|
||||
tariffs: list,
|
||||
hass: HomeAssistant,
|
||||
name: str,
|
||||
unique_id: str | None,
|
||||
) -> TariffSelect:
|
||||
"""Create tariff selection entity."""
|
||||
_LOGGER.debug("Creating utility_meter tariff select: %s", name)
|
||||
|
||||
select_unique_id = None
|
||||
if unique_id:
|
||||
select_unique_id = f"{unique_id}_select"
|
||||
|
||||
tariff_select = TariffSelect(
|
||||
name,
|
||||
tariffs,
|
||||
unique_id=select_unique_id,
|
||||
)
|
||||
tariff_select.entity_id = async_generate_entity_id("select.{}", name, hass=hass)
|
||||
|
||||
key = config_entry.entry_id if config_entry else ""
|
||||
pending = hass.data[DOMAIN].setdefault(DATA_PENDING_SELECT_ENTITIES, {}).setdefault(key, [])
|
||||
pending.append(tariff_select)
|
||||
|
||||
async_dispatcher_send(
|
||||
hass,
|
||||
SIGNAL_CREATE_SELECT_ENTITIES.format(key),
|
||||
[tariff_select],
|
||||
)
|
||||
|
||||
return tariff_select
|
||||
|
||||
|
||||
async def create_utility_meter(
|
||||
hass: HomeAssistant,
|
||||
source_entity: str,
|
||||
entity_id: str,
|
||||
name: str,
|
||||
sensor_config: dict,
|
||||
meter_type: str,
|
||||
unique_id: str | None = None,
|
||||
tariff: str | None = None,
|
||||
tariff_entity: str | None = None,
|
||||
) -> VirtualUtilityMeter:
|
||||
"""Create a utility meter entity, one per tariff."""
|
||||
parent_meter = entity_id
|
||||
if tariff:
|
||||
name = f"{name} {tariff}"
|
||||
entity_id = f"{entity_id}_{slugify(tariff)}"
|
||||
if unique_id:
|
||||
unique_id = f"{unique_id}_{tariff}"
|
||||
|
||||
_LOGGER.debug("Creating utility_meter sensor: %s (entity_id=%s)", name, entity_id)
|
||||
|
||||
params = {
|
||||
"hass": hass,
|
||||
"source_entity": source_entity,
|
||||
"name": name,
|
||||
"meter_type": meter_type,
|
||||
"meter_offset": sensor_config.get(CONF_UTILITY_METER_OFFSET, DEFAULT_OFFSET),
|
||||
"net_consumption": bool(sensor_config.get(CONF_UTILITY_METER_NET_CONSUMPTION, False)),
|
||||
"tariff": tariff,
|
||||
"tariff_entity": tariff_entity,
|
||||
"parent_meter": parent_meter,
|
||||
"delta_values": False,
|
||||
"cron_pattern": None,
|
||||
"periodically_resetting": False,
|
||||
"sensor_always_available": sensor_config.get(CONF_IGNORE_UNAVAILABLE_STATE) or False,
|
||||
"unique_id": unique_id,
|
||||
}
|
||||
|
||||
signature = inspect.signature(UtilityMeterSensor.__init__)
|
||||
|
||||
params = {key: value for key, value in params.items() if key in signature.parameters}
|
||||
|
||||
utility_meter = VirtualUtilityMeter(**params) # type: ignore[no-untyped-call]
|
||||
utility_meter.rounding_digits = int(sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION))
|
||||
utility_meter.entity_id = entity_id
|
||||
|
||||
return utility_meter
|
||||
|
||||
|
||||
class VirtualUtilityMeter(UtilityMeterSensor, BaseEntity):
|
||||
rounding_digits: int = DEFAULT_ENERGY_SENSOR_PRECISION
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return the unique id."""
|
||||
return self._attr_unique_id
|
||||
|
||||
@property
|
||||
def suggested_display_precision(self) -> int | None:
|
||||
"""Return the suggested number of decimal digits for display."""
|
||||
return self.rounding_digits
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | Decimal:
|
||||
"""Return the state of the sensor."""
|
||||
value = self._state if hasattr(self, "_state") else self._attr_native_value # pre HA 2024.12 value was stored in _state
|
||||
if self.rounding_digits and value is not None:
|
||||
return Decimal(round(value, self.rounding_digits)) # type: ignore
|
||||
|
||||
return value # type: ignore
|
||||
Reference in New Issue
Block a user