Skip to content

Commit

Permalink
Implement and/or filters
Browse files Browse the repository at this point in the history
  • Loading branch information
bramstroker committed Dec 10, 2023
1 parent e80dca2 commit 151d56f
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 51 deletions.
2 changes: 2 additions & 0 deletions custom_components/powercalc/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@
CONF_UTILITY_METER_OFFSET = "utility_meter_offset"
CONF_UTILITY_METER_TYPES = "utility_meter_types"
CONF_UTILITY_METER_TARIFFS = "utility_meter_tariffs"
CONF_OR = "or"
CONF_AND = "and"

# Redefine constants from integration component.
# Has been refactored in HA 2022.4, we need to support older HA versions as well.
Expand Down
65 changes: 43 additions & 22 deletions custom_components/powercalc/group_include/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType

from custom_components.powercalc.const import CONF_AREA, CONF_GROUP, CONF_TEMPLATE, CONF_WILDCARD
from custom_components.powercalc.const import CONF_AREA, CONF_GROUP, CONF_OR, CONF_TEMPLATE, CONF_WILDCARD
from custom_components.powercalc.errors import SensorConfigurationError

if AwesomeVersion(HA_VERSION) >= AwesomeVersion("2023.8.0"):
Expand All @@ -32,28 +32,42 @@ class FilterOperator(StrEnum):
OR = "or"


def create_filter(filter_config: ConfigType, hass: HomeAssistant, filter_operator: FilterOperator) -> IncludeEntityFilter:
def create_composite_filter(filter_configs: ConfigType | list[ConfigType], hass: HomeAssistant,
filter_operator: FilterOperator) -> IncludeEntityFilter:
"""Create filter class."""
filters: list[IncludeEntityFilter] = []
for key, val in filter_config.items():
entity_filter: IncludeEntityFilter | None = None
if key == CONF_DOMAIN:
entity_filter = DomainFilter(val)
if key == CONF_AREA:
entity_filter = AreaFilter(hass, val)
if key == CONF_WILDCARD:
entity_filter = WildcardFilter(val)
if key == CONF_GROUP:
entity_filter = GroupFilter(hass, val)
if key == CONF_TEMPLATE:
entity_filter = TemplateFilter(hass, val)

if entity_filter:
filters.append(entity_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():
filters.append(create_filter(key, val, hass))

if len(filters) == 0:
return NullFilter()

return CompositeFilter(filters, filter_operator)


def create_filter(filter_type: str, filter_config: ConfigType, hass: HomeAssistant) -> IncludeEntityFilter:
if filter_type == CONF_DOMAIN:
return DomainFilter(filter_config) # type: ignore
if filter_type == CONF_AREA:
return AreaFilter(hass, filter_config) # type: ignore
if filter_type == CONF_WILDCARD:
return WildcardFilter(filter_config) # type: ignore
if filter_type == CONF_GROUP:
return GroupFilter(hass, filter_config) # type: ignore
if filter_type == CONF_TEMPLATE:
return TemplateFilter(hass, filter_config) # type: ignore
if filter_type == CONF_OR:
return create_composite_filter(filter_config, hass, FilterOperator.OR)
if filter_type == CONF_OR:
return create_composite_filter(filter_config, hass, FilterOperator.AND)
return NullFilter()


class IncludeEntityFilter(Protocol):
def is_valid(self, entity: RegistryEntry) -> bool:
"""Return True when the entity should be included, False when it should be discarded."""
Expand All @@ -68,14 +82,17 @@ def is_valid(self, entity: RegistryEntry) -> bool:
return entity.domain in self.domain
return entity.domain == self.domain


class GroupFilter(IncludeEntityFilter):
def __init__(self, hass: HomeAssistant, group_id: str) -> None:
domain = split_entity_id(group_id)[0]
self.filter = LightGroupFilter(hass, group_id) if domain == LIGHT_DOMAIN else StandardGroupFilter(hass, group_id)
self.filter = LightGroupFilter(hass, group_id) if domain == LIGHT_DOMAIN else StandardGroupFilter(hass,
group_id)

def is_valid(self, entity: RegistryEntry) -> bool:
return self.filter.is_valid(entity)


class StandardGroupFilter(IncludeEntityFilter):
def __init__(self, hass: HomeAssistant, group_id: str) -> None:
entity_reg = entity_registry.async_get(hass)
Expand Down Expand Up @@ -104,7 +121,8 @@ def __init__(self, hass: HomeAssistant, group_id: str) -> None:
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]:
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(
Expand Down Expand Up @@ -134,6 +152,7 @@ class NullFilter(IncludeEntityFilter):
def is_valid(self, entity: RegistryEntry) -> bool:
return True


class WildcardFilter(IncludeEntityFilter):
def __init__(self, pattern: str) -> None:
self.regex = self.create_regex(pattern)
Expand All @@ -146,6 +165,7 @@ def create_regex(pattern: str) -> str:
pattern = pattern.replace("?", ".")
return pattern.replace("*", ".*")


class TemplateFilter(IncludeEntityFilter):
def __init__(self, hass: HomeAssistant, template: Template) -> None:
template.hass = hass
Expand Down Expand Up @@ -175,11 +195,12 @@ def __init__(self, hass: HomeAssistant, area_id_or_name: str) -> None:
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.area_id == self.area.id or entity.device_id in self.area_devices


class CompositeFilter(IncludeEntityFilter):
def __init__(
self,
filters: list[IncludeEntityFilter],
operator: FilterOperator,
self,
filters: list[IncludeEntityFilter],
operator: FilterOperator,
) -> None:
self.filters = filters
self.operator = operator
Expand Down
6 changes: 3 additions & 3 deletions custom_components/powercalc/group_include/include.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from .filter import (
CompositeFilter,
FilterOperator,
create_filter,
create_composite_filter,
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -82,11 +82,11 @@ def resolve_include_source_entities(
hass: HomeAssistant,
include_config: dict,
) -> dict[str, entity_registry.RegistryEntry | None]:
entity_filter = create_filter(include_config, hass, FilterOperator.OR)
entity_filter = create_composite_filter(include_config, hass, FilterOperator.OR)

if CONF_FILTER in include_config:
entity_filter = CompositeFilter(
[entity_filter, create_filter(include_config.get(CONF_FILTER), hass, FilterOperator.OR)], # type: ignore
[entity_filter, create_composite_filter(include_config.get(CONF_FILTER), hass, FilterOperator.OR)], # type: ignore
FilterOperator.AND,
)

Expand Down
20 changes: 14 additions & 6 deletions custom_components/powercalc/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
validate_name_pattern,
)
from .const import (
CONF_AND,
CONF_AREA,
CONF_CALCULATION_ENABLED_CONDITION,
CONF_CALIBRATE,
Expand Down Expand Up @@ -76,6 +77,7 @@
CONF_MULTIPLY_FACTOR,
CONF_MULTIPLY_FACTOR_STANDBY,
CONF_ON_TIME,
CONF_OR,
CONF_PLAYBOOK,
CONF_POWER,
CONF_POWER_SENSOR_CATEGORY,
Expand Down Expand Up @@ -150,6 +152,14 @@

MAX_GROUP_NESTING_LEVEL = 5

FILTER_CONFIG = vol.Schema({
vol.Optional(CONF_AREA): cv.string,
vol.Optional(CONF_GROUP): cv.entity_id,
vol.Optional(CONF_TEMPLATE): cv.template,
vol.Optional(CONF_DOMAIN): cv.string,
vol.Optional(CONF_WILDCARD): cv.string,
})

SENSOR_CONFIG = {
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_ENTITY_ID): cv.entity_id,
Expand Down Expand Up @@ -194,14 +204,12 @@
vol.Optional(CONF_HIDE_MEMBERS): cv.boolean,
vol.Optional(CONF_INCLUDE): vol.Schema(
{
vol.Optional(CONF_AREA): cv.string,
vol.Optional(CONF_GROUP): cv.entity_id,
vol.Optional(CONF_TEMPLATE): cv.template,
vol.Optional(CONF_DOMAIN): cv.string,
vol.Optional(CONF_WILDCARD): cv.string,
**FILTER_CONFIG.schema,
vol.Optional(CONF_FILTER): vol.Schema(
{
vol.Required(CONF_DOMAIN): vol.Any(cv.string, [cv.string]),
**FILTER_CONFIG.schema,
vol.Optional(CONF_OR): vol.All(cv.ensure_list, [FILTER_CONFIG]),
vol.Optional(CONF_AND): vol.All(cv.ensure_list, [FILTER_CONFIG]),
},
),
},
Expand Down
34 changes: 14 additions & 20 deletions docs/source/sensor-types/group/include-entities.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ Domain
include:
domain: light
You might also filter by multiple domains:

.. code-block:: yaml
powercalc:
sensors:
- create_group: All lights
include:
domain:
- light
- switch
Wildcard
--------

Expand Down Expand Up @@ -95,9 +107,9 @@ Filters
=======

Besides the base filters described above which build the base include you can also apply additional filters to further narrow down the list of items.
These filters accept the same configuration as described above.

Domain
------
For example to include all light entities from area outdoor.

.. code-block:: yaml
Expand All @@ -109,21 +121,3 @@ Domain
filter:
domain: light
This will include only light entities from area outdoor.

You can also filter by multiple domains:

.. code-block:: yaml
filter:
domain:
- light
- switch
Wildcard
--------

.. code-block:: yaml
filter:
wildcard: light.office_spot_*
58 changes: 58 additions & 0 deletions tests/group_include/test_include.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
CONF_FIXED,
CONF_GROUP,
CONF_INCLUDE,
CONF_OR,
CONF_POWER,
CONF_SENSOR_TYPE,
CONF_SUB_GROUPS,
Expand Down Expand Up @@ -725,6 +726,63 @@ async def test_include_by_wildcard(
assert group_state
assert group_state.attributes.get(CONF_ENTITIES) == {"sensor.tv_power"}

async def test_include_complex_nested_filters(hass: HomeAssistant, area_reg: AreaRegistry) -> None:
area = area_reg.async_get_or_create("Living room")
mock_registry(
hass,
{
"switch.test": RegistryEntry(
entity_id="binary_sensor.test",
unique_id="1111",
platform="binary_sensor",
),
"switch.tv": RegistryEntry(
entity_id="switch.tv",
unique_id="2222",
platform="switch",
area_id=area.id,
),
"light.tv_ambilights": RegistryEntry(
entity_id="light.tv_ambilights",
unique_id="3333",
platform="light",
area_id=area.id,
),
"light.living_room": RegistryEntry(
entity_id="light.living_room",
unique_id="4444",
platform="light",
area_id=area.id,
),
},
)

await run_powercalc_setup(
hass,
[
get_simple_fixed_config("switch.test"),
get_simple_fixed_config("switch.tv"),
get_simple_fixed_config("light.tv_ambilights"),
get_simple_fixed_config("light.living_room"),
{
CONF_CREATE_GROUP: "Test include",
CONF_INCLUDE: {
CONF_AREA: "Living room",
CONF_FILTER: {
CONF_OR: [
{ CONF_DOMAIN: "switch" },
{ CONF_WILDCARD: "*ambilights" },
],
},
},
},
],
)

group_state = hass.states.get("sensor.test_include_power")
assert group_state
assert group_state.attributes.get(CONF_ENTITIES) == {"sensor.tv_power", "sensor.tv_ambilights_power"}

def _create_powercalc_config_entry(
hass: HomeAssistant,
source_entity_id: str,
Expand Down

0 comments on commit 151d56f

Please sign in to comment.