Initial Commit

This commit is contained in:
2026-06-11 11:50:50 -04:00
commit d4a69c41be
2748 changed files with 80489 additions and 0 deletions
@@ -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),
)