Initial Commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,381 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from enum import StrEnum
|
||||
import re
|
||||
from typing import Protocol, cast
|
||||
|
||||
from homeassistant.components.group import DOMAIN as GROUP_DOMAIN
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_DOMAIN, EntityCategory
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers import area_registry, device_registry, entity_registry, floor_registry, label_registry
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_ALL,
|
||||
CONF_AND,
|
||||
CONF_AREA,
|
||||
CONF_CATEGORY,
|
||||
CONF_FILTER,
|
||||
CONF_FLOOR,
|
||||
CONF_GROUP,
|
||||
CONF_LABEL,
|
||||
CONF_NOT,
|
||||
CONF_OR,
|
||||
CONF_TEMPLATE,
|
||||
CONF_WILDCARD,
|
||||
)
|
||||
from custom_components.powercalc.errors import SensorConfigurationError
|
||||
|
||||
|
||||
class FilterOperator(StrEnum):
|
||||
AND = "and"
|
||||
OR = "or"
|
||||
NOT = "not"
|
||||
|
||||
|
||||
FILTER_CONFIG = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ALL): None,
|
||||
vol.Optional(CONF_AREA): vol.Any(vol.All(cv.ensure_list, [cv.string]), cv.string),
|
||||
vol.Optional(CONF_CATEGORY): vol.Any(vol.All(cv.ensure_list, [cv.string]), cv.string),
|
||||
vol.Optional(CONF_FLOOR): vol.Any(vol.All(cv.ensure_list, [cv.string]), cv.string),
|
||||
vol.Optional(CONF_GROUP): vol.Any(vol.All(cv.ensure_list, [cv.entity_id]), cv.entity_id),
|
||||
vol.Optional(CONF_DOMAIN): vol.Any(vol.All(cv.ensure_list, [cv.string]), cv.string),
|
||||
vol.Optional(CONF_LABEL): vol.Any(vol.All(cv.ensure_list, [cv.string]), cv.string),
|
||||
vol.Optional(CONF_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_WILDCARD): cv.string,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_composite_filter(
|
||||
filter_configs: ConfigType | list[ConfigType],
|
||||
hass: HomeAssistant,
|
||||
filter_operator: FilterOperator,
|
||||
) -> EntityFilter:
|
||||
"""Create filter class."""
|
||||
filters: list[EntityFilter] = []
|
||||
|
||||
if CONF_FILTER in filter_configs and isinstance(filter_configs, dict):
|
||||
filter_configs.update(filter_configs[CONF_FILTER])
|
||||
filter_configs.pop(CONF_FILTER)
|
||||
|
||||
if not isinstance(filter_configs, list):
|
||||
filter_configs = [{key: value} for key, value in filter_configs.items()]
|
||||
|
||||
for filter_config in filter_configs:
|
||||
for key, val in filter_config.items():
|
||||
filter_instance = create_filter(key, val, hass)
|
||||
filters.append(filter_instance)
|
||||
|
||||
if filter_operator == FilterOperator.NOT:
|
||||
return NotFilter(CompositeFilter(filters))
|
||||
|
||||
return CompositeFilter(filters, filter_operator)
|
||||
|
||||
|
||||
def create_filter(
|
||||
filter_type: str,
|
||||
filter_config: ConfigType | str | list | Template,
|
||||
hass: HomeAssistant,
|
||||
) -> EntityFilter:
|
||||
filter_mapping: dict[str, Callable[[], EntityFilter]] = {
|
||||
CONF_DOMAIN: lambda: DomainFilter(filter_config), # type: ignore
|
||||
CONF_AREA: lambda: AreaFilter(hass, filter_config), # type: ignore
|
||||
CONF_CATEGORY: lambda: CategoryFilter(filter_config), # type: ignore
|
||||
CONF_FLOOR: lambda: FloorFilter(hass, filter_config), # type: ignore
|
||||
CONF_LABEL: lambda: LabelFilter(hass, filter_config), # type: ignore
|
||||
CONF_WILDCARD: lambda: WildcardFilter(filter_config), # type: ignore
|
||||
CONF_GROUP: lambda: GroupFilter(hass, filter_config), # type: ignore
|
||||
CONF_TEMPLATE: lambda: TemplateFilter(hass, filter_config), # type: ignore
|
||||
CONF_ALL: lambda: NullFilter(),
|
||||
CONF_OR: lambda: create_composite_filter(filter_config, hass, FilterOperator.OR), # type: ignore
|
||||
CONF_AND: lambda: create_composite_filter(filter_config, hass, FilterOperator.AND), # type: ignore
|
||||
CONF_NOT: lambda: create_composite_filter(filter_config, hass, FilterOperator.NOT), # type: ignore
|
||||
}
|
||||
|
||||
return filter_mapping.get(filter_type, lambda: NullFilter())()
|
||||
|
||||
|
||||
async def get_filtered_entity_list(
|
||||
hass: HomeAssistant,
|
||||
entity_filter: EntityFilter,
|
||||
) -> list[entity_registry.RegistryEntry]:
|
||||
"""Get a listing of entities from HA registry based on the given filter."""
|
||||
entity_reg = entity_registry.async_get(hass)
|
||||
return [entry for entry in entity_reg.entities.values() if entity_filter.is_valid(entry) and not entry.disabled]
|
||||
|
||||
|
||||
class EntityFilter(Protocol):
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
"""Return True when the entity should be included, False when it should be discarded."""
|
||||
|
||||
|
||||
class DomainFilter(EntityFilter):
|
||||
def __init__(self, domain: str | Iterable[str]) -> None:
|
||||
self.domains = {domain} if isinstance(domain, str) else set(domain)
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return entity.domain in self.domains
|
||||
|
||||
|
||||
class GroupFilter(EntityFilter):
|
||||
def __init__(self, hass: HomeAssistant, group_id: str | Iterable[str]) -> None:
|
||||
group_ids = [group_id] if isinstance(group_id, str) else group_id
|
||||
|
||||
filters = []
|
||||
for single_group_id in group_ids:
|
||||
domain = split_entity_id(single_group_id)[0]
|
||||
filter_instance = LightGroupFilter(hass, single_group_id) if domain == LIGHT_DOMAIN else StandardGroupFilter(hass, single_group_id)
|
||||
filters.append(filter_instance)
|
||||
|
||||
self.filter = CompositeFilter(filters, FilterOperator.OR) if len(filters) > 1 else filters[0]
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return self.filter.is_valid(entity)
|
||||
|
||||
|
||||
class StandardGroupFilter(EntityFilter):
|
||||
def __init__(self, hass: HomeAssistant, group_id: str) -> None:
|
||||
entity_reg = entity_registry.async_get(hass)
|
||||
entity_reg.async_get(group_id)
|
||||
group_state = hass.states.get(group_id)
|
||||
if group_state is None:
|
||||
raise SensorConfigurationError(f"Group state {group_id} not found")
|
||||
self.entity_ids = group_state.attributes.get(ATTR_ENTITY_ID) or []
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return entity.entity_id in self.entity_ids
|
||||
|
||||
|
||||
class LightGroupFilter(EntityFilter):
|
||||
def __init__(self, hass: HomeAssistant, group_id: str) -> None:
|
||||
light_component = cast(EntityComponent, hass.data.get(LIGHT_DOMAIN))
|
||||
light_group = next(
|
||||
filter(
|
||||
lambda entity: entity.entity_id == group_id,
|
||||
light_component.entities,
|
||||
),
|
||||
None,
|
||||
)
|
||||
if light_group is None or light_group.platform.platform_name != GROUP_DOMAIN:
|
||||
raise SensorConfigurationError(f"Light group {group_id} not found")
|
||||
|
||||
self.entity_ids = self.find_all_entity_ids_recursively(hass, group_id, [])
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return entity.entity_id in self.entity_ids
|
||||
|
||||
def find_all_entity_ids_recursively(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
group_entity_id: str,
|
||||
all_entity_ids: list[str],
|
||||
) -> list[str]:
|
||||
entity_reg = entity_registry.async_get(hass)
|
||||
light_component = cast(EntityComponent, hass.data.get(LIGHT_DOMAIN))
|
||||
light_group = next(
|
||||
filter(
|
||||
lambda entity: entity.entity_id == group_entity_id,
|
||||
light_component.entities,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
entity_ids: list[str] = light_group.extra_state_attributes.get(ATTR_ENTITY_ID) # type: ignore
|
||||
for entity_id in entity_ids:
|
||||
registry_entry = entity_reg.async_get(entity_id)
|
||||
if registry_entry is None:
|
||||
continue
|
||||
|
||||
if registry_entry.platform == GROUP_DOMAIN:
|
||||
self.find_all_entity_ids_recursively(
|
||||
hass,
|
||||
registry_entry.entity_id,
|
||||
all_entity_ids,
|
||||
)
|
||||
|
||||
all_entity_ids.append(entity_id)
|
||||
|
||||
return all_entity_ids
|
||||
|
||||
|
||||
class NullFilter(EntityFilter):
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class WildcardFilter(EntityFilter):
|
||||
def __init__(self, pattern: str) -> None:
|
||||
self.regex = self.create_regex(pattern)
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return re.search(self.regex, entity.entity_id) is not None
|
||||
|
||||
@staticmethod
|
||||
def create_regex(pattern: str) -> str:
|
||||
pattern = pattern.replace("?", ".")
|
||||
pattern = pattern.replace("*", ".*")
|
||||
return "^" + pattern + "$"
|
||||
|
||||
|
||||
class TemplateFilter(EntityFilter):
|
||||
def __init__(self, hass: HomeAssistant, template: Template) -> None:
|
||||
template.hass = hass
|
||||
self.entity_ids = template.async_render()
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return entity.entity_id in self.entity_ids
|
||||
|
||||
|
||||
class LabelFilter(EntityFilter):
|
||||
def __init__(self, hass: HomeAssistant, label: str | Iterable[str]) -> None:
|
||||
self._hass = hass
|
||||
labels = [label] if isinstance(label, str) else label
|
||||
self.labels = [self._get_label_id(label) for label in labels]
|
||||
self.devices: set[str] = set()
|
||||
device_reg = device_registry.async_get(hass)
|
||||
for label_id in self.labels:
|
||||
self.devices.update([device.id for device in device_registry.async_entries_for_label(device_reg, label_id)])
|
||||
|
||||
def _get_label_id(self, label: str) -> str:
|
||||
label_reg = label_registry.async_get(self._hass)
|
||||
label_entry = label_reg.async_get_label(label)
|
||||
if label_entry:
|
||||
return label_entry.label_id
|
||||
|
||||
label_entry = label_reg.async_get_label_by_name(str(label))
|
||||
if label_entry:
|
||||
return label_entry.label_id
|
||||
|
||||
raise SensorConfigurationError(
|
||||
f"No label with id or name '{label}' found in your HA instance",
|
||||
)
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return any(label in entity.labels for label in self.labels) or entity.device_id in self.devices
|
||||
|
||||
|
||||
class CategoryFilter(EntityFilter):
|
||||
def __init__(self, categories: EntityCategory | str | Iterable[EntityCategory | str]) -> None:
|
||||
if isinstance(categories, (EntityCategory, str)):
|
||||
categories = [categories]
|
||||
|
||||
self.categories = []
|
||||
for category in categories:
|
||||
if not isinstance(category, EntityCategory):
|
||||
try:
|
||||
self.categories.append(EntityCategory(category))
|
||||
except ValueError as err:
|
||||
raise SensorConfigurationError(f"Invalid entity category: {category}") from err
|
||||
else:
|
||||
self.categories.append(category)
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return entity.entity_category in self.categories
|
||||
|
||||
|
||||
class LambdaFilter(EntityFilter):
|
||||
def __init__(self, func: Callable[[RegistryEntry], bool]) -> None:
|
||||
self.func = func
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return self.func(entity)
|
||||
|
||||
|
||||
class AreaFilter(EntityFilter):
|
||||
def __init__(self, hass: HomeAssistant, area_id: str | Iterable[str]) -> None:
|
||||
self.area_ids: list[str] = []
|
||||
self.area_devices: set[str] = set()
|
||||
|
||||
area_ids = [area_id] if isinstance(area_id, str) else area_id
|
||||
|
||||
area_reg = area_registry.async_get(hass)
|
||||
device_reg = device_registry.async_get(hass)
|
||||
|
||||
for area_id in area_ids:
|
||||
area = area_reg.async_get_area(area_id)
|
||||
if area is None:
|
||||
area = area_reg.async_get_area_by_name(str(area_id))
|
||||
|
||||
if area is None or area.id is None:
|
||||
raise SensorConfigurationError(
|
||||
f"No area with id or name '{area_id}' found in your HA instance",
|
||||
)
|
||||
|
||||
self.area_ids.append(area.id)
|
||||
self.area_devices.update([device.id for device in device_registry.async_entries_for_area(device_reg, area.id)])
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return entity.area_id in self.area_ids or entity.device_id in self.area_devices
|
||||
|
||||
|
||||
class DeviceFilter(EntityFilter):
|
||||
def __init__(self, device: str | set[str]) -> None:
|
||||
self.device: set[str] = {device} if isinstance(device, str) else device
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return entity.device_id in self.device
|
||||
|
||||
|
||||
class FloorFilter(EntityFilter):
|
||||
def __init__(self, hass: HomeAssistant, floor_id: str | Iterable[str]) -> None:
|
||||
self.area_ids: list[str] = []
|
||||
self.devices: list[str] = []
|
||||
|
||||
floor_ids = [floor_id] if isinstance(floor_id, str) else floor_id
|
||||
|
||||
floor_reg = floor_registry.async_get(hass)
|
||||
area_reg = area_registry.async_get(hass)
|
||||
device_reg = device_registry.async_get(hass)
|
||||
|
||||
for single_floor_id in floor_ids:
|
||||
floor = floor_reg.async_get_floor(single_floor_id)
|
||||
if floor is None:
|
||||
floor = floor_reg.async_get_floor_by_name(str(single_floor_id))
|
||||
|
||||
if floor is None or floor.floor_id is None:
|
||||
raise SensorConfigurationError(
|
||||
f"No floor with id or name '{single_floor_id}' found in your HA instance",
|
||||
)
|
||||
|
||||
areas = area_registry.async_entries_for_floor(area_reg, floor.floor_id)
|
||||
self.area_ids.extend([area.id for area in areas if area.id is not None])
|
||||
|
||||
for area in areas:
|
||||
self.devices.extend([device.id for device in device_registry.async_entries_for_area(device_reg, area.id)])
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return entity.area_id in self.area_ids or entity.device_id in self.devices
|
||||
|
||||
|
||||
class CompositeFilter(EntityFilter):
|
||||
def __init__(
|
||||
self,
|
||||
filters: Sequence[EntityFilter],
|
||||
operator: FilterOperator = FilterOperator.AND,
|
||||
) -> None:
|
||||
self.filters = filters
|
||||
self.operator = operator
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
evaluations = [entity_filter.is_valid(entity) for entity_filter in self.filters]
|
||||
if self.operator == FilterOperator.OR:
|
||||
return any(evaluations)
|
||||
|
||||
return all(evaluations)
|
||||
|
||||
|
||||
class NotFilter(EntityFilter):
|
||||
def __init__(self, entity_filter: EntityFilter) -> None:
|
||||
self.entity_filter = entity_filter
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return not self.entity_filter.is_valid(entity)
|
||||
@@ -0,0 +1,121 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from homeassistant.components import sensor
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from custom_components.powercalc.common import create_source_entity
|
||||
from custom_components.powercalc.const import (
|
||||
DATA_CONFIGURED_ENTITIES,
|
||||
DATA_ENTITIES,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.powercalc.discovery import get_power_profile_by_source_entity
|
||||
from custom_components.powercalc.power_profile.power_profile import SUPPORTED_DOMAINS
|
||||
from custom_components.powercalc.sensors.energy import RealEnergySensor
|
||||
from custom_components.powercalc.sensors.power import RealPowerSensor
|
||||
from custom_components.powercalc.sensors.utility_meter import VirtualUtilityMeter
|
||||
|
||||
from .filter import CompositeFilter, DomainFilter, EntityFilter, LambdaFilter, get_filtered_entity_list
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FindEntitiesResult:
|
||||
resolved: list[Entity]
|
||||
discoverable: list[str]
|
||||
|
||||
|
||||
async def find_entities(
|
||||
hass: HomeAssistant,
|
||||
entity_filter: EntityFilter | None = None,
|
||||
include_non_powercalc: bool = True,
|
||||
exclude_utility_meters: bool = True,
|
||||
) -> FindEntitiesResult:
|
||||
"""
|
||||
Based on the given entity filter, fetch all power and energy sensors from the HA instance.
|
||||
"""
|
||||
domain_data = hass.data.get(DOMAIN, {})
|
||||
|
||||
source_entity_powercalc_entity_map: dict[str, list[tuple[Entity, bool]]] = domain_data.get(
|
||||
DATA_CONFIGURED_ENTITIES,
|
||||
{},
|
||||
)
|
||||
powercalc_entities: dict[str, Entity] = domain_data.get(
|
||||
DATA_ENTITIES,
|
||||
{},
|
||||
)
|
||||
|
||||
resolved_entities: list[Entity] = []
|
||||
discoverable_entities: list[str] = []
|
||||
|
||||
source_entities = await get_filtered_entity_list(hass, _build_filter(entity_filter))
|
||||
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG): # pragma: no cover
|
||||
_LOGGER.debug("Source entities: %s", [entity.entity_id for entity in source_entities])
|
||||
|
||||
for source_entity in source_entities:
|
||||
entity_id = source_entity.entity_id
|
||||
|
||||
mapped = source_entity_powercalc_entity_map.get(entity_id)
|
||||
if mapped:
|
||||
resolved_entities.extend(entity for entity, _ in mapped)
|
||||
continue
|
||||
|
||||
existing = powercalc_entities.get(entity_id)
|
||||
if existing:
|
||||
resolved_entities.append(existing)
|
||||
continue
|
||||
|
||||
is_real_sensor = False
|
||||
|
||||
if source_entity.domain == sensor.DOMAIN:
|
||||
if source_entity.platform != DOMAIN and not include_non_powercalc:
|
||||
continue
|
||||
|
||||
device_class = source_entity.device_class or source_entity.original_device_class
|
||||
if device_class == SensorDeviceClass.POWER:
|
||||
resolved_entities.append(RealPowerSensor(entity_id, source_entity.unit_of_measurement))
|
||||
is_real_sensor = True
|
||||
elif device_class == SensorDeviceClass.ENERGY:
|
||||
resolved_entities.append(RealEnergySensor(entity_id))
|
||||
is_real_sensor = True
|
||||
|
||||
# No need to discover a profile for something we already resolved as a real sensor
|
||||
if is_real_sensor:
|
||||
continue
|
||||
|
||||
power_profile = await get_power_profile_by_source_entity(
|
||||
hass,
|
||||
await create_source_entity(entity_id, hass),
|
||||
)
|
||||
if power_profile and not await power_profile.needs_user_configuration and power_profile.is_entity_domain_supported(source_entity):
|
||||
discoverable_entities.append(entity_id)
|
||||
|
||||
if exclude_utility_meters:
|
||||
resolved_entities = [entity for entity in resolved_entities if not isinstance(entity, VirtualUtilityMeter)]
|
||||
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG): # pragma: no cover
|
||||
_LOGGER.debug("Resolved entities: %s", [entity.entity_id for entity in resolved_entities])
|
||||
_LOGGER.debug("Discoverable entities: %s", discoverable_entities)
|
||||
|
||||
return FindEntitiesResult(resolved_entities, discoverable_entities)
|
||||
|
||||
|
||||
def _build_filter(entity_filter: EntityFilter | None) -> EntityFilter:
|
||||
base_filter = CompositeFilter(
|
||||
[
|
||||
DomainFilter(SUPPORTED_DOMAINS),
|
||||
LambdaFilter(lambda entity: entity.platform != "utility_meter"),
|
||||
LambdaFilter(lambda entity: not str(entity.unique_id).startswith("powercalc_standby_group")),
|
||||
LambdaFilter(lambda entity: "tracked_" not in str(entity.unique_id)),
|
||||
LambdaFilter(lambda entity: entity.platform != "tasmota" or not str(entity.entity_id).endswith(("_yesterday", "_today"))),
|
||||
],
|
||||
)
|
||||
if not entity_filter:
|
||||
return base_filter
|
||||
|
||||
return CompositeFilter([base_filter, entity_filter])
|
||||
Reference in New Issue
Block a user