296 lines
11 KiB
Python
296 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal
|
|
from enum import StrEnum
|
|
import logging
|
|
from typing import Any
|
|
|
|
from homeassistant.const import CONF_ATTRIBUTE, CONF_CONDITION, CONF_CONDITIONS, CONF_ENTITY_ID, STATE_OFF
|
|
from homeassistant.core import HomeAssistant, State
|
|
from homeassistant.exceptions import ConditionError
|
|
from homeassistant.helpers.condition import ConditionCheckerType
|
|
import homeassistant.helpers.config_validation as cv
|
|
from homeassistant.helpers.event import TrackTemplate
|
|
from homeassistant.helpers.template import Template
|
|
from homeassistant.helpers.typing import ConfigType
|
|
import voluptuous as vol
|
|
|
|
from custom_components.powercalc.const import (
|
|
CONF_FIXED,
|
|
CONF_LINEAR,
|
|
CONF_LUT,
|
|
CONF_MODE,
|
|
CONF_MULTI_SWITCH,
|
|
CONF_PLAYBOOK,
|
|
CONF_STRATEGIES,
|
|
CONF_WLED,
|
|
)
|
|
from custom_components.powercalc.strategy.fixed import CONFIG_SCHEMA as FIXED_SCHEMA
|
|
from custom_components.powercalc.strategy.linear import CONFIG_SCHEMA as LINEAR_SCHEMA
|
|
from custom_components.powercalc.strategy.multi_switch import CONFIG_SCHEMA as MULTI_SWITCH_SCHEMA
|
|
from custom_components.powercalc.strategy.playbook import CONFIG_SCHEMA as PLAYBOOK_SCHEMA, PlaybookStrategy
|
|
from custom_components.powercalc.strategy.strategy_interface import PowerCalculationStrategyInterface
|
|
from custom_components.powercalc.strategy.wled import CONFIG_SCHEMA as WLED_SCHEMA
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class CompositeMode(StrEnum):
|
|
STOP_AT_FIRST = "stop_at_first"
|
|
SUM_ALL = "sum_all"
|
|
|
|
|
|
class ConditionType(StrEnum):
|
|
"""Condition types supported by the composite strategy.
|
|
|
|
Home Assistant has no constants for these, it uses string literals itself.
|
|
"""
|
|
|
|
AND = "and"
|
|
DEVICE = "device"
|
|
NOT = "not"
|
|
NUMERIC_STATE = "numeric_state"
|
|
OR = "or"
|
|
STATE = "state"
|
|
TEMPLATE = "template"
|
|
|
|
|
|
COMPOUND_CONDITIONS = (ConditionType.AND, ConditionType.OR, ConditionType.NOT)
|
|
ENTITY_CONDITIONS = (ConditionType.STATE, ConditionType.NUMERIC_STATE)
|
|
|
|
DEFAULT_MODE = CompositeMode.STOP_AT_FIRST
|
|
|
|
|
|
def make_entity_id_optional(schema: vol.Schema) -> vol.Schema:
|
|
"""Make entity_id optional in schema."""
|
|
# Copy, the schemas we get passed here are module level globals of Home Assistant itself
|
|
schema_dict = dict(schema.schema)
|
|
schema_dict[vol.Optional(CONF_ENTITY_ID)] = schema_dict.pop(vol.Required(CONF_ENTITY_ID))
|
|
return vol.Schema(schema_dict)
|
|
|
|
|
|
def get_compound_schema(condition_type: ConditionType) -> vol.Schema:
|
|
"""Return the schema for and/or/not conditions.
|
|
|
|
Home Assistant's own compound schemas recurse into `cv.CONDITION_SCHEMA`, which requires entity_id.
|
|
We recurse into our own schema instead, so entity_id stays optional at any nesting level
|
|
and can be defaulted to the source entity.
|
|
"""
|
|
return vol.Schema(
|
|
{
|
|
**cv.CONDITION_BASE_SCHEMA,
|
|
vol.Required(CONF_CONDITION): condition_type.value,
|
|
vol.Required(CONF_CONDITIONS): vol.All(
|
|
cv.ensure_list,
|
|
[lambda value: CONDITION_SCHEMA(value)],
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
def get_numeric_state_schema() -> vol.Schema:
|
|
"""Return the numeric state condition schema. We need to modify it to make entity_id optional."""
|
|
return make_entity_id_optional(cv.NUMERIC_STATE_CONDITION_SCHEMA.validators[0])
|
|
|
|
|
|
def get_state_condition_attribute_schema(value: object) -> dict[str, Any]:
|
|
"""Return the state attribute condition schema. We need to modify it to make entity_id optional."""
|
|
return make_entity_id_optional(cv.STATE_CONDITION_ATTRIBUTE_SCHEMA)(value) # type: ignore[no-any-return]
|
|
|
|
|
|
def get_state_condition_state_schema(value: object) -> dict[str, Any]:
|
|
"""Return the state condition schema. We need to modify it to make entity_id optional."""
|
|
return make_entity_id_optional(cv.STATE_CONDITION_STATE_SCHEMA)(value) # type: ignore[no-any-return]
|
|
|
|
|
|
def get_state_schema(value: object) -> dict[str, Any]:
|
|
"""Validate a state condition."""
|
|
if not isinstance(value, dict):
|
|
raise vol.Invalid("Expected a dictionary") # pragma: no cover
|
|
|
|
if CONF_ATTRIBUTE in value:
|
|
validated: dict[str, Any] = get_state_condition_attribute_schema(value)
|
|
else:
|
|
validated = get_state_condition_state_schema(value)
|
|
|
|
return cv.key_dependency("for", "state")(validated)
|
|
|
|
|
|
CONDITION_SCHEMA: vol.Schema = vol.Schema(
|
|
vol.Any(
|
|
vol.All(
|
|
cv.expand_condition_shorthand,
|
|
cv.key_value_schemas(
|
|
CONF_CONDITION,
|
|
{
|
|
ConditionType.AND: get_compound_schema(ConditionType.AND),
|
|
ConditionType.DEVICE: cv.DEVICE_CONDITION_SCHEMA,
|
|
ConditionType.NOT: get_compound_schema(ConditionType.NOT),
|
|
ConditionType.NUMERIC_STATE: get_numeric_state_schema(),
|
|
ConditionType.OR: get_compound_schema(ConditionType.OR),
|
|
ConditionType.STATE: get_state_schema,
|
|
ConditionType.TEMPLATE: cv.TEMPLATE_CONDITION_SCHEMA,
|
|
},
|
|
),
|
|
),
|
|
cv.dynamic_template_condition_action,
|
|
),
|
|
)
|
|
|
|
LUT_SCHEMA = vol.Schema({})
|
|
|
|
|
|
ITEM_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Optional(CONF_CONDITION): CONDITION_SCHEMA,
|
|
vol.Optional(CONF_FIXED): FIXED_SCHEMA,
|
|
vol.Optional(CONF_LINEAR): LINEAR_SCHEMA,
|
|
vol.Optional(CONF_LUT): LUT_SCHEMA,
|
|
vol.Optional(CONF_WLED): WLED_SCHEMA,
|
|
vol.Optional(CONF_PLAYBOOK): PLAYBOOK_SCHEMA,
|
|
vol.Optional(CONF_MULTI_SWITCH): MULTI_SWITCH_SCHEMA,
|
|
},
|
|
)
|
|
|
|
CONFIG_SCHEMA = vol.Any(
|
|
vol.All(
|
|
cv.ensure_list,
|
|
[
|
|
ITEM_SCHEMA,
|
|
],
|
|
),
|
|
vol.Schema(
|
|
{
|
|
vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In([cls.value for cls in CompositeMode]),
|
|
vol.Optional(CONF_STRATEGIES): vol.All(
|
|
cv.ensure_list,
|
|
[
|
|
ITEM_SCHEMA,
|
|
],
|
|
),
|
|
},
|
|
),
|
|
)
|
|
|
|
|
|
class CompositeStrategy(PowerCalculationStrategyInterface):
|
|
def __init__(self, hass: HomeAssistant, strategies: list[SubStrategy], mode: CompositeMode) -> None:
|
|
self.hass = hass
|
|
self.strategies = strategies
|
|
self.mode = mode
|
|
self.playbook_strategies: list[PlaybookStrategy] = [
|
|
strategy.strategy for strategy in self.strategies if isinstance(strategy.strategy, PlaybookStrategy)
|
|
]
|
|
|
|
async def calculate(self, entity_state: State) -> Decimal | None:
|
|
"""Calculate power consumption based on entity state."""
|
|
await self.stop_active_playbooks()
|
|
|
|
total = Decimal(0)
|
|
for sub_strategy in self.strategies:
|
|
value = await self._calculate_sub_strategy(sub_strategy, entity_state)
|
|
if value is None:
|
|
continue
|
|
if self.mode == CompositeMode.STOP_AT_FIRST:
|
|
return value
|
|
total += value
|
|
|
|
return total if self.mode == CompositeMode.SUM_ALL else None
|
|
|
|
async def _calculate_sub_strategy(self, sub_strategy: SubStrategy, entity_state: State) -> Decimal | None:
|
|
"""Calculate the power for a single sub strategy. Returns None when the sub strategy must be skipped."""
|
|
strategy = sub_strategy.strategy
|
|
|
|
if sub_strategy.condition and not self._condition_matches(sub_strategy.condition, entity_state):
|
|
return None
|
|
|
|
if isinstance(strategy, PlaybookStrategy):
|
|
await self.activate_playbook(strategy)
|
|
|
|
if entity_state.state == STATE_OFF and not strategy.can_calculate_standby():
|
|
return None
|
|
|
|
return await strategy.calculate(entity_state)
|
|
|
|
def _condition_matches(self, condition: ConditionCheckerType, entity_state: State) -> bool:
|
|
try:
|
|
return condition(self.hass, {"state": entity_state})
|
|
except ConditionError:
|
|
_LOGGER.debug("Skipping composite sub-strategy because condition evaluation failed", exc_info=True)
|
|
return False
|
|
|
|
async def stop_active_playbooks(self) -> None:
|
|
"""Stop any active playbooks from sub strategies."""
|
|
for playbook in self.playbook_strategies:
|
|
await playbook.stop_playbook()
|
|
|
|
@staticmethod
|
|
async def activate_playbook(strategy: PlaybookStrategy) -> None:
|
|
"""Activate the first playbook in the list."""
|
|
if not strategy.registered_playbooks:
|
|
return # pragma: no cover
|
|
playbook = strategy.registered_playbooks[0]
|
|
await strategy.activate_playbook(playbook)
|
|
|
|
def set_update_callback(self, update_callback: Callable[[Decimal], None]) -> None:
|
|
"""
|
|
Register update callback which allows to give the strategy instance access to the power sensor
|
|
and manipulate the state
|
|
"""
|
|
for sub_strategy in self.strategies:
|
|
sub_strategy.strategy.set_update_callback(update_callback)
|
|
|
|
async def validate_config(self) -> None:
|
|
"""Validate correct setup of the strategy."""
|
|
for sub_strategy in self.strategies:
|
|
await sub_strategy.strategy.validate_config()
|
|
|
|
def get_entities_to_track(self) -> list[str | TrackTemplate]:
|
|
"""Return entities that should be tracked."""
|
|
track_templates: list[str | TrackTemplate] = []
|
|
for sub_strategy in self.strategies:
|
|
if sub_strategy.condition_config:
|
|
self.resolve_track_templates_from_condition(
|
|
sub_strategy.condition_config,
|
|
track_templates,
|
|
)
|
|
|
|
track_entities = [
|
|
entity for sub_strategy in self.strategies for entity in sub_strategy.strategy.get_entities_to_track()
|
|
]
|
|
return track_templates + track_entities
|
|
|
|
def can_calculate_standby(self) -> bool:
|
|
"""Return if this strategy can calculate standby power."""
|
|
return any(sub_strategy.strategy.can_calculate_standby() for sub_strategy in self.strategies)
|
|
|
|
async def on_start(self, hass: HomeAssistant) -> None:
|
|
"""Called after HA has started"""
|
|
for sub_strategy in self.strategies:
|
|
await sub_strategy.strategy.on_start(hass)
|
|
|
|
def resolve_track_templates_from_condition(
|
|
self,
|
|
condition_config: ConfigType,
|
|
templates: list[str | TrackTemplate],
|
|
) -> None:
|
|
"""Resolve track templates from condition config."""
|
|
for key, value in condition_config.items():
|
|
if key == CONF_ENTITY_ID and isinstance(value, list):
|
|
templates.extend(value)
|
|
if isinstance(value, Template):
|
|
templates.append(TrackTemplate(value, None, None))
|
|
if isinstance(value, list):
|
|
for item in value:
|
|
if isinstance(item, dict):
|
|
self.resolve_track_templates_from_condition(item, templates)
|
|
|
|
|
|
@dataclass
|
|
class SubStrategy:
|
|
condition_config: ConfigType | None
|
|
condition: ConditionCheckerType | None
|
|
strategy: PowerCalculationStrategyInterface
|