Initial Commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,240 @@
|
||||
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_ENTITY_ID, STATE_OFF
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
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
|
||||
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"
|
||||
|
||||
|
||||
DEFAULT_MODE = CompositeMode.STOP_AT_FIRST
|
||||
|
||||
|
||||
def make_entity_id_optional(schema: vol.Schema) -> vol.Schema:
|
||||
"""Make entity_id optional in schema."""
|
||||
schema = schema.schema
|
||||
schema[vol.Optional(CONF_ENTITY_ID)] = schema.pop(vol.Required(CONF_ENTITY_ID)) # type: ignore
|
||||
return vol.Schema(schema)
|
||||
|
||||
|
||||
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: Any) -> dict[str, Any]: # noqa: ANN401
|
||||
"""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
|
||||
|
||||
|
||||
def get_state_condition_state_schema(value: Any) -> dict[str, Any]: # noqa: ANN401
|
||||
"""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
|
||||
|
||||
|
||||
def get_state_schema(value: Any) -> dict[str, Any]: # noqa: ANN401
|
||||
"""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,
|
||||
{
|
||||
"and": cv.AND_CONDITION_SCHEMA,
|
||||
"device": cv.DEVICE_CONDITION_SCHEMA,
|
||||
"not": cv.NOT_CONDITION_SCHEMA,
|
||||
"numeric_state": get_numeric_state_schema(),
|
||||
"or": cv.OR_CONDITION_SCHEMA,
|
||||
"state": get_state_schema,
|
||||
"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:
|
||||
strategy = sub_strategy.strategy
|
||||
|
||||
if sub_strategy.condition and not sub_strategy.condition(self.hass, {"state": entity_state}):
|
||||
continue
|
||||
|
||||
if isinstance(strategy, PlaybookStrategy):
|
||||
await self.activate_playbook(strategy)
|
||||
|
||||
if (entity_state.state == STATE_OFF and strategy.can_calculate_standby()) or entity_state.state != STATE_OFF:
|
||||
value = await strategy.calculate(entity_state)
|
||||
if value is not None:
|
||||
if self.mode == CompositeMode.STOP_AT_FIRST:
|
||||
return value
|
||||
total += value
|
||||
|
||||
return total if self.mode == CompositeMode.SUM_ALL else None
|
||||
|
||||
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:
|
||||
if hasattr(sub_strategy.strategy, "set_update_callback"):
|
||||
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: dict,
|
||||
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: dict | None
|
||||
condition: ConditionCheckerType | None
|
||||
strategy: PowerCalculationStrategyInterface
|
||||
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from decimal import Decimal
|
||||
from typing import Any, cast
|
||||
|
||||
from homeassistant.const import CONF_CONDITION, CONF_ENTITIES, CONF_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import condition
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_COMPOSITE,
|
||||
CONF_MODE,
|
||||
CONF_MULTI_SWITCH,
|
||||
CONF_POWER,
|
||||
CONF_POWER_OFF,
|
||||
CONF_POWER_TEMPLATE,
|
||||
CONF_STANDBY_POWER,
|
||||
CONF_STATE,
|
||||
CONF_STATES_POWER,
|
||||
CONF_STRATEGIES,
|
||||
CalculationStrategy,
|
||||
)
|
||||
from custom_components.powercalc.errors import (
|
||||
StrategyConfigurationError,
|
||||
UnsupportedStrategyError,
|
||||
)
|
||||
from custom_components.powercalc.power_profile.power_profile import PowerProfile
|
||||
|
||||
from .composite import DEFAULT_MODE, CompositeStrategy, SubStrategy
|
||||
from .fixed import FixedStrategy
|
||||
from .linear import LinearStrategy
|
||||
from .lut import LutRegistry, LutStrategy
|
||||
from .multi_switch import MultiSwitchStrategy
|
||||
from .playbook import PlaybookStrategy
|
||||
from .selector import detect_calculation_strategy
|
||||
from .strategy_interface import PowerCalculationStrategyInterface
|
||||
from .wled import WledStrategy
|
||||
|
||||
|
||||
class PowerCalculatorStrategyFactory:
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
self._hass = hass
|
||||
self._lut_registry = LutRegistry(hass)
|
||||
|
||||
@staticmethod
|
||||
@singleton("powercalc_strategy_factory")
|
||||
def get_instance(hass: HomeAssistant) -> PowerCalculatorStrategyFactory:
|
||||
return PowerCalculatorStrategyFactory(hass)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
config: dict,
|
||||
strategy: str,
|
||||
power_profile: PowerProfile | None,
|
||||
source_entity: SourceEntity,
|
||||
) -> PowerCalculationStrategyInterface:
|
||||
"""Create instance of calculation strategy based on configuration."""
|
||||
strategy_mapping: dict[str, Callable[[], PowerCalculationStrategyInterface]] = {
|
||||
CalculationStrategy.LINEAR: lambda: self._create_linear(source_entity, config, power_profile),
|
||||
CalculationStrategy.FIXED: lambda: self._create_fixed(source_entity, config, power_profile),
|
||||
CalculationStrategy.LUT: lambda: self._create_lut(source_entity, power_profile),
|
||||
CalculationStrategy.MULTI_SWITCH: lambda: self._create_multi_switch(config, power_profile),
|
||||
CalculationStrategy.PLAYBOOK: lambda: self._create_playbook(config, power_profile),
|
||||
CalculationStrategy.WLED: lambda: self._create_wled(source_entity, config),
|
||||
}
|
||||
|
||||
if strategy == CalculationStrategy.COMPOSITE:
|
||||
return await self._prepare(
|
||||
await self._create_composite(config, source_entity, power_profile),
|
||||
)
|
||||
|
||||
if strategy in strategy_mapping:
|
||||
return await self._prepare(
|
||||
strategy_mapping[strategy](),
|
||||
)
|
||||
|
||||
raise UnsupportedStrategyError("Invalid calculation strategy", strategy)
|
||||
|
||||
@staticmethod
|
||||
async def _prepare(instance: PowerCalculationStrategyInterface) -> PowerCalculationStrategyInterface:
|
||||
await instance.validate_config()
|
||||
await instance.initialize()
|
||||
return instance
|
||||
|
||||
def _create_linear(
|
||||
self,
|
||||
source_entity: SourceEntity,
|
||||
config: dict,
|
||||
power_profile: PowerProfile | None,
|
||||
) -> LinearStrategy:
|
||||
"""Create the linear strategy."""
|
||||
linear_config = self._get_strategy_config(CalculationStrategy.LINEAR, config, power_profile)
|
||||
|
||||
return LinearStrategy(
|
||||
linear_config,
|
||||
self._hass,
|
||||
source_entity,
|
||||
config.get(CONF_STANDBY_POWER),
|
||||
)
|
||||
|
||||
def _create_fixed(
|
||||
self,
|
||||
source_entity: SourceEntity,
|
||||
config: dict,
|
||||
power_profile: PowerProfile | None,
|
||||
) -> FixedStrategy:
|
||||
"""Create the fixed strategy."""
|
||||
fixed_config = self._get_strategy_config(CalculationStrategy.FIXED, config, power_profile)
|
||||
|
||||
power = fixed_config.get(CONF_POWER)
|
||||
if power is None:
|
||||
power = fixed_config.get(CONF_POWER_TEMPLATE)
|
||||
power = self._resolve_template(power)
|
||||
|
||||
states_power = fixed_config.get(CONF_STATES_POWER)
|
||||
if states_power:
|
||||
# Handle both list format (config flow) and dict format (YAML)
|
||||
if isinstance(states_power, list):
|
||||
states_power = {item[CONF_STATE]: item[CONF_POWER] for item in states_power}
|
||||
states_power = {state: self._resolve_template(value) for state, value in states_power.items()}
|
||||
|
||||
return FixedStrategy(source_entity, power, states_power)
|
||||
|
||||
def _create_lut(
|
||||
self,
|
||||
source_entity: SourceEntity,
|
||||
power_profile: PowerProfile | None,
|
||||
) -> LutStrategy:
|
||||
"""Create the lut strategy."""
|
||||
if power_profile is None:
|
||||
raise StrategyConfigurationError(
|
||||
"You must supply a valid manufacturer and model to use the LUT mode",
|
||||
)
|
||||
|
||||
return LutStrategy(source_entity, self._lut_registry, power_profile)
|
||||
|
||||
def _create_wled(self, source_entity: SourceEntity, config: dict) -> WledStrategy:
|
||||
"""Create the WLED strategy."""
|
||||
wled_config = self._get_strategy_config(CalculationStrategy.WLED, config, None)
|
||||
return WledStrategy(
|
||||
config=wled_config,
|
||||
light_entity=source_entity,
|
||||
hass=self._hass,
|
||||
standby_power=config.get(CONF_STANDBY_POWER),
|
||||
)
|
||||
|
||||
def _create_playbook(self, config: ConfigType, power_profile: PowerProfile | None) -> PlaybookStrategy:
|
||||
playbook_config = self._get_strategy_config(CalculationStrategy.PLAYBOOK, config, power_profile)
|
||||
|
||||
directory = None
|
||||
if power_profile:
|
||||
directory = power_profile.get_model_directory()
|
||||
|
||||
return PlaybookStrategy(self._hass, playbook_config, directory)
|
||||
|
||||
async def _create_composite(
|
||||
self,
|
||||
config: ConfigType,
|
||||
source_entity: SourceEntity,
|
||||
power_profile: PowerProfile | None,
|
||||
) -> CompositeStrategy:
|
||||
composite_config: list | dict | None = config.get(CONF_COMPOSITE)
|
||||
if composite_config is None:
|
||||
if power_profile and power_profile.composite_config:
|
||||
composite_config = power_profile.composite_config
|
||||
else:
|
||||
raise StrategyConfigurationError("No composite configuration supplied")
|
||||
|
||||
sub_strategies = composite_config
|
||||
mode = DEFAULT_MODE
|
||||
if isinstance(composite_config, dict):
|
||||
mode = composite_config.get(CONF_MODE, DEFAULT_MODE)
|
||||
sub_strategies = composite_config.get(CONF_STRATEGIES) # type: ignore
|
||||
|
||||
async def _create_sub_strategy(strategy_config: ConfigType) -> SubStrategy:
|
||||
condition_instance = None
|
||||
condition_config = strategy_config.get(CONF_CONDITION)
|
||||
if condition_config:
|
||||
condition_type = condition_config.get(CONF_CONDITION)
|
||||
if condition_type in ["state", "numeric_state"] and CONF_ENTITY_ID not in condition_config:
|
||||
condition_config[CONF_ENTITY_ID] = [source_entity.entity_id]
|
||||
if condition_type == "state":
|
||||
condition_config = condition.state_validate_config(self._hass, condition_config)
|
||||
condition_instance = await condition.async_from_config(
|
||||
self._hass,
|
||||
condition_config,
|
||||
)
|
||||
|
||||
strategy = detect_calculation_strategy(strategy_config, power_profile)
|
||||
strategy_instance = await self.create(
|
||||
strategy_config,
|
||||
strategy,
|
||||
power_profile,
|
||||
source_entity,
|
||||
)
|
||||
return SubStrategy(condition_config, condition_instance, strategy_instance) # type: ignore
|
||||
|
||||
strategies = [await _create_sub_strategy(config) for config in sub_strategies]
|
||||
return CompositeStrategy(self._hass, strategies, mode)
|
||||
|
||||
def _create_multi_switch(self, config: ConfigType, power_profile: PowerProfile | None) -> MultiSwitchStrategy:
|
||||
"""Create instance of multi switch strategy."""
|
||||
multi_switch_config: ConfigType = {}
|
||||
if power_profile and power_profile.multi_switch_config:
|
||||
multi_switch_config = power_profile.multi_switch_config
|
||||
multi_switch_config.update(config.get(CONF_MULTI_SWITCH, {}))
|
||||
|
||||
if not multi_switch_config:
|
||||
raise StrategyConfigurationError("No multi_switch configuration supplied")
|
||||
|
||||
entities: list[str] = multi_switch_config.get(CONF_ENTITIES, [])
|
||||
|
||||
on_power: Decimal | None = multi_switch_config.get(CONF_POWER)
|
||||
off_power: Decimal | None = multi_switch_config.get(CONF_POWER_OFF)
|
||||
if on_power is None:
|
||||
raise StrategyConfigurationError("No power configuration supplied")
|
||||
|
||||
return MultiSwitchStrategy(
|
||||
self._hass,
|
||||
entities,
|
||||
on_power=Decimal(on_power),
|
||||
off_power=Decimal(off_power) if off_power else None,
|
||||
)
|
||||
|
||||
def _resolve_template(self, value: Any) -> Any: # noqa: ANN401
|
||||
"""
|
||||
Process the input to ensure it is a Template if applicable.
|
||||
Otherwise, return the original value.
|
||||
"""
|
||||
if isinstance(value, str) and value.startswith("{{"):
|
||||
return Template(value, self._hass)
|
||||
if isinstance(value, Template):
|
||||
value.hass = self._hass
|
||||
return value
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _get_strategy_config(
|
||||
strategy: CalculationStrategy,
|
||||
config: ConfigType,
|
||||
power_profile: PowerProfile | None,
|
||||
) -> ConfigType:
|
||||
"""Get the strategy configuration."""
|
||||
if strategy in config:
|
||||
return cast(ConfigType, config[strategy])
|
||||
|
||||
prop = f"{strategy}_config"
|
||||
if power_profile and getattr(power_profile, prop):
|
||||
return cast(ConfigType, getattr(power_profile, prop))
|
||||
|
||||
raise StrategyConfigurationError(f"No {strategy} configuration supplied")
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from homeassistant.components import lawn_mower, vacuum
|
||||
from homeassistant.core import State
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import TrackTemplate
|
||||
from homeassistant.helpers.template import Template
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.const import CONF_POWER, CONF_STATES_POWER
|
||||
from custom_components.powercalc.errors import StrategyConfigurationError
|
||||
from custom_components.powercalc.helpers import evaluate_power
|
||||
|
||||
from .strategy_interface import PowerCalculationStrategyInterface
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_POWER): vol.Any(vol.Coerce(float), cv.template),
|
||||
vol.Optional(CONF_STATES_POWER): vol.Schema(
|
||||
{cv.string: vol.Any(vol.Coerce(float), cv.template)},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
STATE_BASED_ENTITY_DOMAINS = [
|
||||
vacuum.DOMAIN,
|
||||
lawn_mower.DOMAIN,
|
||||
]
|
||||
|
||||
|
||||
class FixedStrategy(PowerCalculationStrategyInterface):
|
||||
def __init__(
|
||||
self,
|
||||
source_entity: SourceEntity,
|
||||
power: Template | float | None,
|
||||
per_state_power: dict[str, float | Template] | None,
|
||||
) -> None:
|
||||
self._source_entity = source_entity
|
||||
self._power = power
|
||||
self._per_state_power = per_state_power
|
||||
|
||||
async def calculate(self, entity_state: State) -> Decimal | None:
|
||||
if self._per_state_power is not None:
|
||||
# Lookup by state
|
||||
if entity_state.state in self._per_state_power:
|
||||
return await evaluate_power(
|
||||
self._per_state_power.get(entity_state.state) or 0,
|
||||
)
|
||||
|
||||
# Lookup by state attribute (attribute|value)
|
||||
for state_key, power in self._per_state_power.items():
|
||||
if "|" in state_key:
|
||||
attribute, value = state_key.split("|", 2)
|
||||
if str(entity_state.attributes.get(attribute)) == value:
|
||||
return await evaluate_power(power)
|
||||
|
||||
if self._power is None:
|
||||
return None
|
||||
|
||||
return await evaluate_power(self._power)
|
||||
|
||||
async def validate_config(self) -> None:
|
||||
"""Validate correct setup of the strategy."""
|
||||
if self._power is None and self._per_state_power is None:
|
||||
raise StrategyConfigurationError(
|
||||
"You must supply one of 'states_power' or 'power'",
|
||||
"fixed_mandatory",
|
||||
)
|
||||
|
||||
if self._source_entity.domain in STATE_BASED_ENTITY_DOMAINS and self._per_state_power is None:
|
||||
raise StrategyConfigurationError(
|
||||
"This entity can only work with 'states_power' not 'power'",
|
||||
"fixed_states_power_only",
|
||||
)
|
||||
|
||||
def get_entities_to_track(self) -> list[str | TrackTemplate]:
|
||||
"""Return entities that should be tracked."""
|
||||
track_templates: list[str | TrackTemplate] = []
|
||||
|
||||
if isinstance(self._power, Template):
|
||||
track_templates.append(TrackTemplate(self._power, None, None))
|
||||
|
||||
if self._per_state_power:
|
||||
for power in list(self._per_state_power.values()):
|
||||
if isinstance(power, Template):
|
||||
track_templates.append(TrackTemplate(power, None, None)) # noqa: PERF401
|
||||
|
||||
return track_templates
|
||||
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import fan, lawn_mower, light, media_player, vacuum
|
||||
from homeassistant.components.fan import ATTR_PERCENTAGE
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
STATE_PLAYING,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CONF_ATTRIBUTE
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import TrackTemplate
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity, create_source_entity
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_CALIBRATE,
|
||||
CONF_GAMMA_CURVE,
|
||||
CONF_MAX_POWER,
|
||||
CONF_MIN_POWER,
|
||||
)
|
||||
from custom_components.powercalc.errors import StrategyConfigurationError
|
||||
from custom_components.powercalc.helpers import get_related_entity_by_device_class
|
||||
|
||||
from .strategy_interface import PowerCalculationStrategyInterface
|
||||
|
||||
ALLOWED_DOMAINS = [fan.DOMAIN, light.DOMAIN, media_player.DOMAIN, vacuum.DOMAIN, lawn_mower.DOMAIN]
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_CALIBRATE): vol.All(
|
||||
cv.ensure_list,
|
||||
[vol.Match("^[0-9]+ -> ([0-9]*[.])?[0-9]+$")],
|
||||
),
|
||||
vol.Optional(CONF_MIN_POWER): vol.Coerce(float),
|
||||
vol.Optional(CONF_MAX_POWER): vol.Coerce(float),
|
||||
vol.Optional(CONF_GAMMA_CURVE): vol.Coerce(float),
|
||||
vol.Optional(CONF_ATTRIBUTE): cv.string,
|
||||
},
|
||||
)
|
||||
|
||||
ENTITY_ATTRIBUTE_MAPPING = {
|
||||
fan.DOMAIN: ATTR_PERCENTAGE,
|
||||
light.DOMAIN: ATTR_BRIGHTNESS,
|
||||
media_player.DOMAIN: ATTR_MEDIA_VOLUME_LEVEL,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LinearStrategy(PowerCalculationStrategyInterface):
|
||||
def __init__(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
hass: HomeAssistant,
|
||||
source_entity: SourceEntity,
|
||||
standby_power: float | None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._hass = hass
|
||||
self._source_entity: SourceEntity = source_entity
|
||||
self._value_entity: SourceEntity | None = None
|
||||
self._attribute: str | None = None
|
||||
self._standby_power = standby_power
|
||||
self._initialized: bool = False
|
||||
self._calibration: list[tuple[int, float]] | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the strategy, called once on creation."""
|
||||
self._value_entity = await self.get_value_entity()
|
||||
self._calibration = self.create_calibrate_list()
|
||||
|
||||
async def calculate(self, entity_state: State) -> Decimal | None:
|
||||
"""Calculate the current power consumption."""
|
||||
if not self._initialized:
|
||||
self._attribute = self.get_attribute(entity_state)
|
||||
self._initialized = True
|
||||
|
||||
value = self.get_current_state_value(entity_state)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
min_calibrate = self.get_min_calibrate(value)
|
||||
max_calibrate = self.get_max_calibrate(value)
|
||||
min_value = min_calibrate[0]
|
||||
max_value = max_calibrate[0]
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Linear mode state value: %d range(%d-%d)",
|
||||
self._value_entity.entity_id, # type: ignore
|
||||
value,
|
||||
min_value,
|
||||
max_value,
|
||||
)
|
||||
|
||||
min_power = min_calibrate[1]
|
||||
max_power = max_calibrate[1]
|
||||
|
||||
value_range = max_value - min_value
|
||||
power_range = max_power - min_power
|
||||
|
||||
gamma_curve = self._config.get(CONF_GAMMA_CURVE) or 1
|
||||
|
||||
relative_value = (value - min_value) / value_range
|
||||
|
||||
power = power_range * relative_value**gamma_curve + min_power
|
||||
|
||||
return Decimal(power)
|
||||
|
||||
def is_enabled(self, entity_state: State) -> bool:
|
||||
"""Return if this strategy is enabled based on entity state."""
|
||||
if self._source_entity.domain == media_player.DOMAIN and entity_state.state is not STATE_PLAYING: # noqa: SIM103
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_min_calibrate(self, value: int) -> tuple[int, float]:
|
||||
"""Get closest lower value from calibration table."""
|
||||
return min(self._calibration or (), key=lambda v: (v[0] > value, value - v[0]))
|
||||
|
||||
def get_max_calibrate(self, value: int) -> tuple[int, float]:
|
||||
"""Get closest higher value from calibration table."""
|
||||
return max(self._calibration or (), key=lambda v: (v[0] > value, value - v[0]))
|
||||
|
||||
def create_calibrate_list(self) -> list[tuple[int, float]]:
|
||||
"""Build a table of calibration values."""
|
||||
calibration_list: list[tuple[int, float]] = []
|
||||
|
||||
calibrate = self._config.get(CONF_CALIBRATE)
|
||||
if isinstance(calibrate, dict):
|
||||
calibrate = [f"{key} -> {value}" for key, value in calibrate.items()]
|
||||
|
||||
if calibrate is None or len(calibrate) == 0:
|
||||
full_range = self.get_entity_value_range()
|
||||
min_value = full_range[0]
|
||||
max_value = full_range[1]
|
||||
min_power = self._config.get(CONF_MIN_POWER) or self._standby_power or 0
|
||||
calibration_list.append((min_value, float(min_power)))
|
||||
calibration_list.append(
|
||||
(max_value, float(self._config.get(CONF_MAX_POWER))), # type: ignore
|
||||
)
|
||||
return calibration_list
|
||||
|
||||
for line in calibrate:
|
||||
parts = line.split(" -> ")
|
||||
calibration_list.append((int(parts[0]), float(parts[1])))
|
||||
|
||||
return sorted(calibration_list, key=lambda tup: tup[0])
|
||||
|
||||
def get_entity_value_range(self) -> tuple:
|
||||
"""Get the min/max range for a given entity domain."""
|
||||
if self._value_entity.domain == light.DOMAIN: # type: ignore
|
||||
return 0, 255
|
||||
|
||||
return 0, 100
|
||||
|
||||
def get_current_state_value(self, entity_state: State) -> int | None:
|
||||
"""Get the current entity state, i.e. selected brightness."""
|
||||
if self._attribute:
|
||||
return self.get_value_from_attribute(entity_state)
|
||||
|
||||
if self._value_entity.entity_id is not self._source_entity.entity_id: # type: ignore
|
||||
# If the value entity is different from the source entity, we need to fetch the state of the value entity
|
||||
entity_state = self._hass.states.get(self._value_entity.entity_id) # type: ignore
|
||||
if not entity_state:
|
||||
_LOGGER.error(
|
||||
"Value entity %s not found",
|
||||
self._value_entity.entity_id, # type: ignore
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(float(entity_state.state))
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
"Expecting state to be a number for entity: %s",
|
||||
entity_state.entity_id,
|
||||
)
|
||||
return None
|
||||
|
||||
def get_value_from_attribute(self, entity_state: State) -> int | None:
|
||||
value: int | None = entity_state.attributes.get(self._attribute) # type: ignore[arg-type]
|
||||
if value is None:
|
||||
_LOGGER.warning(
|
||||
"No %s attribute for entity: %s",
|
||||
self._attribute,
|
||||
entity_state.entity_id,
|
||||
)
|
||||
return None
|
||||
if self._attribute == ATTR_BRIGHTNESS and value > 255:
|
||||
value = 255
|
||||
# Convert volume level to 0-100 range
|
||||
if self._attribute == ATTR_MEDIA_VOLUME_LEVEL:
|
||||
if entity_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True:
|
||||
value = 0
|
||||
value *= 100
|
||||
return value
|
||||
|
||||
def get_attribute(self, entity_state: State) -> str | None:
|
||||
"""Returns the attribute which contains the value for the linear calculation."""
|
||||
if CONF_ATTRIBUTE in self._config:
|
||||
return str(self._config.get(CONF_ATTRIBUTE))
|
||||
|
||||
entity_domain = entity_state.domain
|
||||
return ENTITY_ATTRIBUTE_MAPPING.get(entity_domain)
|
||||
|
||||
async def validate_config(self) -> None:
|
||||
"""Validate correct setup of the strategy."""
|
||||
if not self._config.get(CONF_CALIBRATE):
|
||||
if self._source_entity.domain not in ALLOWED_DOMAINS:
|
||||
raise StrategyConfigurationError(
|
||||
"Entity domain not supported for linear mode. Must be one of: {}, or use the calibrate option".format(
|
||||
",".join(ALLOWED_DOMAINS),
|
||||
),
|
||||
"linear_unsupported_domain",
|
||||
)
|
||||
if CONF_MAX_POWER not in self._config:
|
||||
raise StrategyConfigurationError(
|
||||
"Linear strategy must have at least 'max power' or 'calibrate' defined",
|
||||
"linear_mandatory",
|
||||
)
|
||||
|
||||
min_power = self._config.get(CONF_MIN_POWER)
|
||||
max_power = self._config.get(CONF_MAX_POWER)
|
||||
if min_power and max_power and min_power >= max_power:
|
||||
raise StrategyConfigurationError(
|
||||
"Max power cannot be lower than min power",
|
||||
"linear_min_higher_as_max",
|
||||
)
|
||||
|
||||
async def get_value_entity(self) -> SourceEntity:
|
||||
"""Set the value entity based on the current state."""
|
||||
if self._source_entity.domain in (vacuum.DOMAIN, lawn_mower.DOMAIN) and self._attribute is None and self._source_entity.entity_entry:
|
||||
# For vacuum cleaner and lawn mower, battery level is a separate entity
|
||||
related_entity = get_related_entity_by_device_class(
|
||||
self._hass,
|
||||
self._source_entity,
|
||||
SensorDeviceClass.BATTERY,
|
||||
)
|
||||
if not related_entity:
|
||||
raise StrategyConfigurationError(
|
||||
"No battery entity found for vacuum cleaner",
|
||||
"linear_no_battery_entity",
|
||||
)
|
||||
return await create_source_entity(related_entity, self._hass)
|
||||
|
||||
return self._value_entity or self._source_entity
|
||||
|
||||
def get_entities_to_track(self) -> list[str | TrackTemplate]:
|
||||
"""Return entities to track for this strategy."""
|
||||
if self._value_entity and self._value_entity.entity_id != self._source_entity.entity_id:
|
||||
return [self._value_entity.entity_id]
|
||||
|
||||
return []
|
||||
@@ -0,0 +1,431 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bisect import bisect_left
|
||||
from collections.abc import Mapping
|
||||
from csv import reader
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from enum import StrEnum
|
||||
from functools import partial
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, TextIO, TypeVar, cast
|
||||
|
||||
from homeassistant.components import light
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_EFFECT,
|
||||
ATTR_HS_COLOR,
|
||||
COLOR_MODES_COLOR,
|
||||
ColorMode,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.util.color import color_temperature_kelvin_to_mired, color_temperature_to_hs
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.errors import (
|
||||
LutFileNotFoundError,
|
||||
StrategyConfigurationError,
|
||||
)
|
||||
from custom_components.powercalc.power_profile.power_profile import PowerProfile
|
||||
|
||||
from .strategy_interface import PowerCalculationStrategyInterface
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BrightnessLutValue = float
|
||||
ColorTempLutValue = dict[int, float]
|
||||
SatLutValue = dict[int, float]
|
||||
HsLutValue = dict[int, SatLutValue]
|
||||
LookupDictValue = BrightnessLutValue | ColorTempLutValue | HsLutValue
|
||||
LookupDictType = dict[int, LookupDictValue]
|
||||
EffectTableType = dict[str, dict[int, float]]
|
||||
|
||||
|
||||
class LookupMode(StrEnum):
|
||||
EFFECT = "effect"
|
||||
BRIGHTNESS = "brightness"
|
||||
COLOR_TEMP = "color_temp"
|
||||
HS = "hs"
|
||||
|
||||
@staticmethod
|
||||
def from_color_mode(color_mode: ColorMode) -> LookupMode:
|
||||
return LookupMode(color_mode.value)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _LutEntry:
|
||||
"""Holds a lookup dictionary together with its pre-sorted brightness key list."""
|
||||
|
||||
table: LookupDictType
|
||||
sorted_keys: list[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _EffectEntry:
|
||||
"""Holds an effect lookup table (str → {brightness: power})."""
|
||||
|
||||
table: EffectTableType
|
||||
|
||||
|
||||
class LutRegistry:
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
self._hass = hass
|
||||
self._lut_entries: dict[tuple, _LutEntry] = {}
|
||||
self._effect_entries: dict[tuple, _EffectEntry] = {}
|
||||
self._supported_modes: dict[tuple, set[LookupMode]] = {}
|
||||
|
||||
async def get_lookup_entry(
|
||||
self,
|
||||
power_profile: PowerProfile,
|
||||
lookup_mode: LookupMode,
|
||||
) -> _LutEntry:
|
||||
"""Return a cached _LutEntry for the given profile and mode."""
|
||||
cache_key = self._cache_key(power_profile, lookup_mode)
|
||||
entry = self._lut_entries.get(cache_key)
|
||||
if entry is None:
|
||||
entry = await self._hass.async_add_executor_job(partial(self._load_lut_entry, power_profile, lookup_mode))
|
||||
self._lut_entries[cache_key] = entry
|
||||
return entry
|
||||
|
||||
async def get_effect_entry(
|
||||
self,
|
||||
power_profile: PowerProfile,
|
||||
) -> _EffectEntry:
|
||||
"""Return a cached _EffectEntry for the given profile."""
|
||||
cache_key = self._cache_key(power_profile, LookupMode.EFFECT)
|
||||
entry = self._effect_entries.get(cache_key)
|
||||
if entry is None:
|
||||
entry = await self._hass.async_add_executor_job(partial(self._load_effect_entry, power_profile))
|
||||
self._effect_entries[cache_key] = entry
|
||||
return entry
|
||||
|
||||
async def get_supported_modes(self, power_profile: PowerProfile) -> set[LookupMode]:
|
||||
"""Return the LUT modes supported by the profile."""
|
||||
cache_key = (power_profile.manufacturer, power_profile.model, "supported_modes")
|
||||
supported_modes = self._supported_modes.get(cache_key)
|
||||
if supported_modes is None:
|
||||
supported_modes = set()
|
||||
for filename in await self._hass.async_add_executor_job(os.listdir, power_profile.get_model_directory()):
|
||||
if filename.endswith((".csv.gz", ".csv")):
|
||||
base_name = filename.split(".", 1)[0]
|
||||
supported_modes.add(LookupMode(base_name))
|
||||
self._supported_modes[cache_key] = supported_modes
|
||||
return supported_modes
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(power_profile: PowerProfile, lookup_mode: LookupMode) -> tuple:
|
||||
return power_profile.manufacturer, power_profile.model, lookup_mode, power_profile.sub_profile
|
||||
|
||||
@classmethod
|
||||
def _load_lut_entry(cls, power_profile: PowerProfile, lookup_mode: LookupMode) -> _LutEntry:
|
||||
"""Load a non-effect CSV into a typed _LutEntry."""
|
||||
raw: dict[int, Any] = {}
|
||||
|
||||
csv_file = cls.get_lut_file(power_profile, lookup_mode)
|
||||
line_count = 0
|
||||
with csv_file:
|
||||
csv_reader = reader(csv_file)
|
||||
next(csv_reader) # skip header row
|
||||
for row in csv_reader:
|
||||
if lookup_mode == LookupMode.HS:
|
||||
bri_key = int(row[0])
|
||||
hue_key = int(row[1])
|
||||
sat_key = int(row[2])
|
||||
raw.setdefault(bri_key, {}).setdefault(hue_key, {})[sat_key] = float(row[3])
|
||||
elif lookup_mode == LookupMode.COLOR_TEMP:
|
||||
bri_key = int(row[0])
|
||||
ct_key = int(row[1])
|
||||
raw.setdefault(bri_key, {})[ct_key] = float(row[2])
|
||||
else:
|
||||
raw[int(row[0])] = float(row[1])
|
||||
line_count += 1
|
||||
|
||||
_LOGGER.debug("LUT file loaded: %d lines", line_count)
|
||||
table = cast(LookupDictType, raw)
|
||||
return _LutEntry(table=table, sorted_keys=sorted(table.keys()))
|
||||
|
||||
@classmethod
|
||||
def _load_effect_entry(cls, power_profile: PowerProfile) -> _EffectEntry:
|
||||
"""Load an effect CSV into a typed _EffectEntry."""
|
||||
raw: dict[str, dict[int, float]] = {}
|
||||
|
||||
csv_file = cls.get_lut_file(power_profile, LookupMode.EFFECT)
|
||||
line_count = 0
|
||||
with csv_file:
|
||||
csv_reader = reader(csv_file)
|
||||
next(csv_reader) # skip header row
|
||||
for row in csv_reader:
|
||||
effect_name: str = row[0]
|
||||
bri_key = int(row[1])
|
||||
raw.setdefault(effect_name, {})[bri_key] = float(row[2])
|
||||
line_count += 1
|
||||
|
||||
_LOGGER.debug("Effect LUT file loaded: %d lines", line_count)
|
||||
return _EffectEntry(table=raw)
|
||||
|
||||
@staticmethod
|
||||
def get_lut_file(power_profile: PowerProfile, lookup_mode: LookupMode) -> TextIO:
|
||||
"""
|
||||
Open the LUT file for the given power profile and color mode.
|
||||
When the file is gzipped it will be decompressed transparently.
|
||||
"""
|
||||
path = os.path.join(power_profile.get_model_directory(), f"{lookup_mode}.csv")
|
||||
|
||||
gzip_path = f"{path}.gz"
|
||||
if os.path.exists(gzip_path):
|
||||
_LOGGER.debug("Loading LUT data file: %s", gzip_path)
|
||||
return gzip.open(gzip_path, "rt")
|
||||
|
||||
if os.path.exists(path):
|
||||
_LOGGER.debug("Loading LUT data file: %s", path)
|
||||
return open(path)
|
||||
|
||||
raise LutFileNotFoundError("Data file not found: %s")
|
||||
|
||||
|
||||
class LutStrategy(PowerCalculationStrategyInterface):
|
||||
def __init__(
|
||||
self,
|
||||
source_entity: SourceEntity,
|
||||
lut_registry: LutRegistry,
|
||||
profile: PowerProfile,
|
||||
) -> None:
|
||||
self._source_entity = source_entity
|
||||
self._lut_registry = lut_registry
|
||||
self._profile = profile
|
||||
self._supported_modes: set[LookupMode] = set()
|
||||
self._effect_entry: _EffectEntry | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
self._supported_modes = await self._lut_registry.get_supported_modes(self._profile)
|
||||
|
||||
async def calculate(self, entity_state: State) -> Decimal | None:
|
||||
"""Calculate the power consumption based on brightness, mired, hsl or effect."""
|
||||
attrs = entity_state.attributes
|
||||
|
||||
brightness = attrs.get(ATTR_BRIGHTNESS)
|
||||
if brightness is None:
|
||||
_LOGGER.error(
|
||||
"%s: Could not calculate power. no brightness set",
|
||||
entity_state.entity_id,
|
||||
)
|
||||
return None
|
||||
if brightness > 255:
|
||||
brightness = 255
|
||||
|
||||
color_mode = await self.get_selected_color_mode(attrs)
|
||||
if color_mode == ColorMode.UNKNOWN:
|
||||
_LOGGER.warning(
|
||||
"%s: Could not calculate power. color mode unknown",
|
||||
entity_state.entity_id,
|
||||
)
|
||||
return None
|
||||
|
||||
effect = attrs.get(ATTR_EFFECT)
|
||||
if effect and str(effect).lower() not in ("off", "none", "white"):
|
||||
return await self._calculate_effect_power(entity_state, str(effect), brightness)
|
||||
|
||||
lut_mode = LookupMode.from_color_mode(color_mode)
|
||||
|
||||
try:
|
||||
lut_entry = await self._lut_registry.get_lookup_entry(self._profile, lut_mode)
|
||||
except LutFileNotFoundError:
|
||||
_LOGGER.error(
|
||||
"%s: Lookup table not found for color mode (model: %s, color_mode: %s)",
|
||||
entity_state.entity_id,
|
||||
self._profile.model,
|
||||
color_mode,
|
||||
)
|
||||
return None
|
||||
|
||||
light_setting = self.create_light_setting(entity_state, color_mode, brightness)
|
||||
if light_setting is None:
|
||||
return None
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Looking up power usage with settings: %s",
|
||||
entity_state.entity_id,
|
||||
{attr: getattr(light_setting, attr) for attr in vars(light_setting)},
|
||||
)
|
||||
|
||||
power = self.lookup_power(lut_entry, light_setting)
|
||||
_LOGGER.debug("%s: Calculated power:%s", entity_state.entity_id, power)
|
||||
return Decimal(power)
|
||||
|
||||
async def _calculate_effect_power(
|
||||
self,
|
||||
entity_state: State,
|
||||
effect: str,
|
||||
brightness: int,
|
||||
) -> Decimal | None:
|
||||
"""Look up power for an active light effect."""
|
||||
if LookupMode.EFFECT not in self._supported_modes:
|
||||
_LOGGER.warning("%s: Effects not supported for this power profile", entity_state.entity_id)
|
||||
return None
|
||||
|
||||
effect_entry = await self._lut_registry.get_effect_entry(self._profile)
|
||||
effect_table = effect_entry.table.get(effect)
|
||||
if effect_table is None:
|
||||
_LOGGER.warning('%s: Effect "%s" not found in LUT', entity_state.entity_id, effect)
|
||||
return None
|
||||
|
||||
sorted_keys = sorted(effect_table.keys())
|
||||
return Decimal(self._interpolate(effect_table, sorted_keys, brightness))
|
||||
|
||||
def create_light_setting(
|
||||
self,
|
||||
entity_state: State,
|
||||
color_mode: ColorMode,
|
||||
brightness: int,
|
||||
) -> LightSetting | None:
|
||||
"""Create a LightSetting object based on the entity state."""
|
||||
light_setting = LightSetting(color_mode=color_mode, brightness=brightness)
|
||||
|
||||
attrs = entity_state.attributes
|
||||
if color_mode == ColorMode.COLOR_TEMP:
|
||||
color_temp = attrs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
if color_temp is None:
|
||||
_LOGGER.error(
|
||||
"%s: Could not calculate power. no color temp set. Please check the attributes of your light in the developer tools.",
|
||||
entity_state.entity_id,
|
||||
)
|
||||
return None
|
||||
light_setting.color_temp = color_temperature_kelvin_to_mired(color_temp)
|
||||
return light_setting
|
||||
|
||||
if color_mode == ColorMode.HS:
|
||||
try:
|
||||
original_color_mode = attrs.get(ATTR_COLOR_MODE)
|
||||
hs = color_temperature_to_hs(attrs[ATTR_COLOR_TEMP_KELVIN]) if original_color_mode == ColorMode.COLOR_TEMP else attrs[ATTR_HS_COLOR]
|
||||
light_setting.hue = int(hs[0] / 360 * 65535)
|
||||
light_setting.saturation = int(hs[1] / 100 * 255)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.error(
|
||||
"%s: Could not calculate power. no hue/sat set. Please check the attributes of your light in the developer tools.",
|
||||
entity_state.entity_id,
|
||||
)
|
||||
return None
|
||||
|
||||
return light_setting
|
||||
|
||||
async def get_selected_color_mode(self, attrs: Mapping[str, Any]) -> ColorMode:
|
||||
"""Get the selected color mode for the entity."""
|
||||
try:
|
||||
color_mode = ColorMode(str(attrs.get(ATTR_COLOR_MODE, ColorMode.UNKNOWN)))
|
||||
except ValueError:
|
||||
color_mode = ColorMode.UNKNOWN
|
||||
if color_mode == ColorMode.WHITE:
|
||||
return ColorMode.BRIGHTNESS
|
||||
if color_mode == ColorMode.UNKNOWN:
|
||||
return color_mode
|
||||
if color_mode in COLOR_MODES_COLOR:
|
||||
color_mode = ColorMode.HS
|
||||
lookup_mode = LookupMode.from_color_mode(color_mode)
|
||||
if lookup_mode not in self._supported_modes and color_mode == ColorMode.COLOR_TEMP:
|
||||
_LOGGER.debug("Color mode not natively supported, falling back to HS")
|
||||
color_mode = ColorMode.HS
|
||||
return color_mode
|
||||
|
||||
@staticmethod
|
||||
def _nearest_key(sorted_keys: list[int], x: int) -> int:
|
||||
"""Return the key in sorted_keys nearest to x."""
|
||||
i = bisect_left(sorted_keys, x)
|
||||
if i == 0:
|
||||
return sorted_keys[0]
|
||||
if i >= len(sorted_keys):
|
||||
return sorted_keys[-1]
|
||||
before = sorted_keys[i - 1]
|
||||
after = sorted_keys[i]
|
||||
return before if (x - before) <= (after - x) else after
|
||||
|
||||
@staticmethod
|
||||
def _interpolate(table: dict[int, float], sorted_keys: list[int], brightness: int) -> float:
|
||||
"""Linear interpolation over a flat {brightness: power} table."""
|
||||
if brightness in table:
|
||||
return table[brightness]
|
||||
|
||||
i = bisect_left(sorted_keys, brightness)
|
||||
if i == 0:
|
||||
return table[sorted_keys[0]]
|
||||
if i >= len(sorted_keys):
|
||||
return table[sorted_keys[-1]]
|
||||
|
||||
b0 = sorted_keys[i - 1]
|
||||
b1 = sorted_keys[i]
|
||||
p0 = table[b0]
|
||||
p1 = table[b1]
|
||||
return p0 + (p1 - p0) * ((brightness - b0) / (b1 - b0))
|
||||
|
||||
def lookup_power(
|
||||
self,
|
||||
lut_entry: _LutEntry,
|
||||
light_setting: LightSetting,
|
||||
) -> float:
|
||||
lookup_table = lut_entry.table
|
||||
sorted_keys = lut_entry.sorted_keys
|
||||
brightness = light_setting.brightness
|
||||
|
||||
# Exact brightness match — skip interpolation entirely.
|
||||
if brightness in lookup_table:
|
||||
return self.lookup_power_for_brightness(lookup_table[brightness], light_setting)
|
||||
|
||||
i = bisect_left(lut_entry.sorted_keys, brightness)
|
||||
|
||||
if i == 0:
|
||||
return self.lookup_power_for_brightness(lookup_table[sorted_keys[0]], light_setting)
|
||||
if i >= len(sorted_keys):
|
||||
return self.lookup_power_for_brightness(lookup_table[sorted_keys[-1]], light_setting)
|
||||
|
||||
b0 = sorted_keys[i - 1]
|
||||
b1 = sorted_keys[i]
|
||||
p0 = self.lookup_power_for_brightness(lookup_table[b0], light_setting)
|
||||
p1 = self.lookup_power_for_brightness(lookup_table[b1], light_setting)
|
||||
return p0 + (p1 - p0) * ((brightness - b0) / (b1 - b0))
|
||||
|
||||
def lookup_power_for_brightness(
|
||||
self,
|
||||
lut_value: LookupDictValue,
|
||||
light_setting: LightSetting,
|
||||
) -> float:
|
||||
if isinstance(lut_value, float):
|
||||
return lut_value
|
||||
|
||||
if light_setting.color_mode == ColorMode.COLOR_TEMP:
|
||||
return self.get_nearest(lut_value, light_setting.color_temp or 0) # type: ignore
|
||||
|
||||
# HS path — outer dict is hue → {saturation → power}
|
||||
hs_table = cast(HsLutValue, lut_value)
|
||||
sat_values = self.get_nearest(hs_table, light_setting.hue or 0)
|
||||
return self.get_nearest(sat_values, light_setting.saturation or 0)
|
||||
|
||||
# Generic nearest lookup for both float values and nested saturation dicts
|
||||
_NearestT = TypeVar("_NearestT", float, dict[int, float])
|
||||
|
||||
def get_nearest(self, lookup_dict: dict[int, _NearestT], search_key: int) -> _NearestT:
|
||||
"""Return the value mapped at search_key or the nearest neighbour key."""
|
||||
value = lookup_dict.get(search_key)
|
||||
if value is not None:
|
||||
return value
|
||||
nearest = self._nearest_key(sorted(lookup_dict.keys()), search_key)
|
||||
return lookup_dict[nearest]
|
||||
|
||||
async def validate_config(self) -> None:
|
||||
if self._source_entity.domain != light.DOMAIN:
|
||||
raise StrategyConfigurationError(
|
||||
"Only light entities can use the LUT mode",
|
||||
"lut_unsupported_color_mode",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LightSetting:
|
||||
color_mode: ColorMode
|
||||
brightness: int
|
||||
hue: int | None = None
|
||||
saturation: int | None = None
|
||||
color_temp: int | None = None
|
||||
effect: str | None = None
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import CONF_ENTITIES, STATE_CLOSING, STATE_ON, STATE_OPENING, STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import TrackTemplate
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.const import CONF_POWER, CONF_POWER_OFF, DUMMY_ENTITY_ID
|
||||
|
||||
from .strategy_interface import PowerCalculationStrategyInterface
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_POWER): vol.Coerce(float),
|
||||
vol.Optional(CONF_POWER_OFF): vol.Coerce(float),
|
||||
vol.Required(CONF_ENTITIES): cv.entities_domain(SWITCH_DOMAIN),
|
||||
},
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ON_STATES = [STATE_ON, STATE_OPENING, STATE_CLOSING]
|
||||
|
||||
|
||||
class MultiSwitchStrategy(PowerCalculationStrategyInterface):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
switch_entities: list[str],
|
||||
on_power: Decimal,
|
||||
off_power: Decimal | None = None,
|
||||
) -> None:
|
||||
self.hass = hass
|
||||
self.switch_entities = switch_entities
|
||||
self.known_states: dict[str, str] | None = None
|
||||
self.on_power = on_power
|
||||
self.off_power = off_power
|
||||
|
||||
async def calculate(self, entity_state: State) -> Decimal | None:
|
||||
if self.known_states is None:
|
||||
self.known_states = {
|
||||
entity_id: (state.state if (state := self.hass.states.get(entity_id)) else STATE_UNAVAILABLE) for entity_id in self.switch_entities
|
||||
}
|
||||
|
||||
if entity_state.entity_id != DUMMY_ENTITY_ID and entity_state.entity_id in self.switch_entities:
|
||||
self.known_states[entity_state.entity_id] = entity_state.state
|
||||
|
||||
def _get_power(state: str) -> Decimal:
|
||||
if state == STATE_UNAVAILABLE:
|
||||
return Decimal(0)
|
||||
if state in ON_STATES:
|
||||
return self.on_power
|
||||
return self.off_power or Decimal(0)
|
||||
|
||||
return Decimal(sum(_get_power(state) for state in self.known_states.values()))
|
||||
|
||||
def get_entities_to_track(self) -> list[str | TrackTemplate]:
|
||||
return self.switch_entities # type: ignore
|
||||
|
||||
def can_calculate_standby(self) -> bool:
|
||||
return self.off_power is not None
|
||||
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
|
||||
from homeassistant.const import STATE_OFF
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_point_in_time
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_AUTOSTART,
|
||||
CONF_PLAYBOOKS,
|
||||
CONF_REPEAT,
|
||||
CONF_STATE_TRIGGER,
|
||||
CONF_STATES_TRIGGER,
|
||||
)
|
||||
from custom_components.powercalc.errors import StrategyConfigurationError
|
||||
|
||||
from .strategy_interface import PowerCalculationStrategyInterface
|
||||
|
||||
CONFIG_SCHEMA = vol.All(
|
||||
cv.deprecated(CONF_STATES_TRIGGER, replacement_key=CONF_STATE_TRIGGER),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_PLAYBOOKS): vol.Schema(
|
||||
{cv.string: cv.string},
|
||||
),
|
||||
vol.Optional(CONF_AUTOSTART): cv.string,
|
||||
vol.Optional(CONF_REPEAT, default=False): cv.boolean,
|
||||
vol.Optional(CONF_STATE_TRIGGER): vol.Schema(
|
||||
{cv.string: cv.string},
|
||||
),
|
||||
vol.Optional(CONF_STATES_TRIGGER): vol.Schema(
|
||||
{cv.string: cv.string},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlaybookStrategy(PowerCalculationStrategyInterface):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
playbook_directory: str | None = None,
|
||||
) -> None:
|
||||
self._hass = hass
|
||||
self._active_playbook: Playbook | None = None
|
||||
self._loaded_playbooks: dict[str, Playbook] = {}
|
||||
self._update_callback: Callable[[Decimal], None] = lambda power: None
|
||||
self._start_time: datetime = dt.utcnow()
|
||||
self._cancel_timer: CALLBACK_TYPE | None = None
|
||||
self._config = config
|
||||
self._repeat: bool = bool(config.get(CONF_REPEAT))
|
||||
self._autostart: str | None = config.get(CONF_AUTOSTART)
|
||||
self._power = Decimal(0)
|
||||
self._states_trigger: dict[str, str] | None = config.get(CONF_STATE_TRIGGER, config.get(CONF_STATES_TRIGGER))
|
||||
self._playbook_directory = playbook_directory or os.path.join(hass.config.config_dir, "powercalc/playbooks")
|
||||
|
||||
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
|
||||
"""
|
||||
self._update_callback = update_callback
|
||||
|
||||
async def calculate(self, entity_state: State) -> Decimal | None:
|
||||
if self._states_trigger:
|
||||
if entity_state.state in self._states_trigger:
|
||||
playbook_id = self._states_trigger[entity_state.state]
|
||||
await self.activate_playbook(playbook_id)
|
||||
else:
|
||||
await self.stop_playbook()
|
||||
|
||||
return self._power
|
||||
|
||||
async def on_start(self, hass: HomeAssistant) -> None:
|
||||
if self._autostart:
|
||||
await self.activate_playbook(self._autostart)
|
||||
|
||||
async def activate_playbook(self, playbook_id: str) -> None:
|
||||
"""Activate and execute a given playbook"""
|
||||
if self._active_playbook:
|
||||
await self.stop_playbook()
|
||||
|
||||
_LOGGER.debug("Activating playbook %s", playbook_id)
|
||||
playbook = await self._load_playbook(playbook_id=playbook_id)
|
||||
playbook.queue.reset()
|
||||
self._active_playbook = playbook
|
||||
self._start_time = dt.utcnow()
|
||||
|
||||
self._execute_playbook_entry()
|
||||
|
||||
async def stop_playbook(self) -> None:
|
||||
"""Activate and execute a given playbook"""
|
||||
if not self._active_playbook:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Stopping playbook")
|
||||
self._active_playbook = None
|
||||
self._power = Decimal(0)
|
||||
if self._cancel_timer is not None:
|
||||
self._cancel_timer()
|
||||
self._cancel_timer = None
|
||||
|
||||
def get_active_playbook(self) -> Playbook | None:
|
||||
"""Get running playbook"""
|
||||
return self._active_playbook
|
||||
|
||||
@callback
|
||||
def _execute_playbook_entry(self) -> None:
|
||||
"""Execute one step of the playbook"""
|
||||
if self._cancel_timer is not None:
|
||||
self._cancel_timer()
|
||||
self._cancel_timer = None
|
||||
|
||||
if not self._active_playbook: # pragma: no cover
|
||||
_LOGGER.error("Could not execute next playbook entry. No active playbook")
|
||||
return
|
||||
|
||||
queue = self._active_playbook.queue
|
||||
if len(queue) == 0:
|
||||
if self._repeat:
|
||||
_LOGGER.debug("Playbook %s repeating", self._active_playbook.key)
|
||||
self._start_time = dt.utcnow()
|
||||
queue.reset()
|
||||
self._execute_playbook_entry()
|
||||
return
|
||||
|
||||
_LOGGER.debug("Playbook %s completed", self._active_playbook.key)
|
||||
self._active_playbook = None
|
||||
return
|
||||
|
||||
entry = queue.dequeue()
|
||||
|
||||
@callback
|
||||
def _update_power(date_time: datetime) -> None:
|
||||
self._power = entry.power
|
||||
_LOGGER.debug("playbook %s: Update power %.2f", self._active_playbook.key, self._power) # type: ignore
|
||||
self._update_callback(self._power)
|
||||
# Schedule next update
|
||||
self._execute_playbook_entry()
|
||||
|
||||
# Schedule update in the future
|
||||
self._cancel_timer = async_track_point_in_time(
|
||||
self._hass,
|
||||
_update_power,
|
||||
self._start_time + timedelta(seconds=entry.time),
|
||||
)
|
||||
|
||||
async def _load_playbook(self, playbook_id: str) -> Playbook:
|
||||
"""Lazy load a playbook from a CSV file"""
|
||||
if playbook_id in self._loaded_playbooks:
|
||||
return self._loaded_playbooks[playbook_id]
|
||||
|
||||
playbooks: dict[str, str] = self._config.get(CONF_PLAYBOOKS) # type: ignore
|
||||
if playbook_id not in playbooks:
|
||||
raise StrategyConfigurationError(
|
||||
f"Playbook with id {playbook_id} not defined in playbooks config",
|
||||
)
|
||||
|
||||
file_path = os.path.join(self._playbook_directory, playbooks[playbook_id])
|
||||
if not (os.path.exists(file_path) or os.path.exists(f"{file_path}.gz")):
|
||||
raise StrategyConfigurationError(
|
||||
f"Playbook file '{file_path}' does not exist",
|
||||
)
|
||||
|
||||
def _load_playbook_entries() -> list[PlaybookEntry]:
|
||||
"""Load playbook entries from a CSV file, with support for gzipped files"""
|
||||
actual_path = file_path if os.path.exists(file_path) else f"{file_path}.gz"
|
||||
open_func = gzip.open if actual_path.endswith(".gz") else open
|
||||
with open_func(actual_path, mode="rt") as csv_file:
|
||||
csv_reader = csv.reader(csv_file)
|
||||
entries = []
|
||||
for row in csv_reader:
|
||||
if len(row) != 2:
|
||||
raise StrategyConfigurationError(
|
||||
f"Playbook file '{actual_path}' has invalid structure, please see the documentation.",
|
||||
)
|
||||
entries.append(PlaybookEntry(time=float(row[0]), power=Decimal(row[1])))
|
||||
return entries
|
||||
|
||||
playbook_entries = await self._hass.async_add_executor_job(_load_playbook_entries)
|
||||
|
||||
self._loaded_playbooks[playbook_id] = Playbook(
|
||||
key=playbook_id,
|
||||
queue=PlaybookQueue(playbook_entries),
|
||||
)
|
||||
return self._loaded_playbooks[playbook_id]
|
||||
|
||||
def can_calculate_standby(self) -> bool:
|
||||
return bool(self._states_trigger and STATE_OFF in self._states_trigger)
|
||||
|
||||
@property
|
||||
def registered_playbooks(self) -> list[str]:
|
||||
playbooks = dict(self._config.get(CONF_PLAYBOOKS, {}))
|
||||
return list(playbooks.keys())
|
||||
|
||||
|
||||
class PlaybookQueue:
|
||||
def __init__(self, items: list[PlaybookEntry]) -> None:
|
||||
self._items = items
|
||||
self._queue: deque[PlaybookEntry] = deque(items)
|
||||
|
||||
def dequeue(self) -> PlaybookEntry:
|
||||
return self._queue.popleft()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._queue = deque(self._items)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._queue)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Playbook:
|
||||
key: str
|
||||
queue: PlaybookQueue
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaybookEntry:
|
||||
time: float
|
||||
power: Decimal
|
||||
@@ -0,0 +1,46 @@
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_COMPOSITE,
|
||||
CONF_FIXED,
|
||||
CONF_LINEAR,
|
||||
CONF_LUT,
|
||||
CONF_MODE,
|
||||
CONF_MULTI_SWITCH,
|
||||
CONF_PLAYBOOK,
|
||||
CONF_WLED,
|
||||
CalculationStrategy,
|
||||
)
|
||||
from custom_components.powercalc.errors import UnsupportedStrategyError
|
||||
from custom_components.powercalc.power_profile.power_profile import PowerProfile
|
||||
|
||||
STRATEGY_CONFIG_MAP = {
|
||||
CONF_LINEAR: CalculationStrategy.LINEAR,
|
||||
CONF_FIXED: CalculationStrategy.FIXED,
|
||||
CONF_MULTI_SWITCH: CalculationStrategy.MULTI_SWITCH,
|
||||
CONF_PLAYBOOK: CalculationStrategy.PLAYBOOK,
|
||||
CONF_WLED: CalculationStrategy.WLED,
|
||||
CONF_LUT: CalculationStrategy.LUT,
|
||||
CONF_COMPOSITE: CalculationStrategy.COMPOSITE,
|
||||
}
|
||||
|
||||
|
||||
def detect_calculation_strategy(
|
||||
config: ConfigType,
|
||||
power_profile: PowerProfile | None,
|
||||
) -> CalculationStrategy:
|
||||
"""Select the calculation strategy."""
|
||||
config_mode = config.get(CONF_MODE)
|
||||
if config_mode:
|
||||
return CalculationStrategy(config_mode)
|
||||
|
||||
for config_key, strategy in STRATEGY_CONFIG_MAP.items():
|
||||
if config_key in config:
|
||||
return strategy
|
||||
|
||||
if power_profile:
|
||||
return power_profile.calculation_strategy
|
||||
|
||||
raise UnsupportedStrategyError(
|
||||
"Cannot select a strategy, supply it in the config. See the readme",
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.event import TrackTemplate
|
||||
|
||||
|
||||
class PowerCalculationStrategyInterface:
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the strategy, called once on creation."""
|
||||
|
||||
async def calculate(self, entity_state: State) -> Decimal | None:
|
||||
"""Calculate power consumption based on entity state."""
|
||||
|
||||
async def validate_config(self) -> None:
|
||||
"""Validate correct setup of the strategy."""
|
||||
|
||||
def get_entities_to_track(self) -> list[str | TrackTemplate]:
|
||||
"""Return entities to track for this strategy."""
|
||||
return []
|
||||
|
||||
def can_calculate_standby(self) -> bool:
|
||||
"""Return if this strategy can calculate standby power."""
|
||||
return False
|
||||
|
||||
def is_enabled(self, entity_state: State) -> bool:
|
||||
"""Return if this strategy is enabled based on entity state."""
|
||||
return True
|
||||
|
||||
async def on_start(self, hass: HomeAssistant) -> None:
|
||||
"""Called after HA has started"""
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.event import TrackTemplate
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_POWER_FACTOR,
|
||||
CONF_VOLTAGE,
|
||||
OFF_STATES,
|
||||
)
|
||||
from custom_components.powercalc.errors import StrategyConfigurationError
|
||||
from custom_components.powercalc.helpers import evaluate_power, get_related_entity_by_device_class
|
||||
|
||||
from .strategy_interface import PowerCalculationStrategyInterface
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_VOLTAGE): vol.Coerce(float),
|
||||
vol.Optional(CONF_POWER_FACTOR, default=0.9): vol.Coerce(float),
|
||||
},
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WledStrategy(PowerCalculationStrategyInterface):
|
||||
def __init__(
|
||||
self,
|
||||
config: ConfigType,
|
||||
light_entity: SourceEntity,
|
||||
hass: HomeAssistant,
|
||||
standby_power: float | None = None,
|
||||
) -> None:
|
||||
self._hass = hass
|
||||
self._voltage = config.get(CONF_VOLTAGE) or 0
|
||||
self._power_factor = config.get(CONF_POWER_FACTOR) or 0.9
|
||||
self._light_entity = light_entity
|
||||
self._standby_power: Decimal = Decimal(standby_power or 0)
|
||||
self._estimated_current_entity: str | None = None
|
||||
|
||||
async def calculate(self, entity_state: State) -> Decimal | None:
|
||||
light_state = entity_state if entity_state.entity_id == self._light_entity.entity_id else self._hass.states.get(self._light_entity.entity_id)
|
||||
|
||||
if light_state.state in OFF_STATES and self._standby_power:
|
||||
return self._standby_power
|
||||
|
||||
if entity_state.entity_id != self._estimated_current_entity:
|
||||
entity_state = self._hass.states.get(self._estimated_current_entity)
|
||||
|
||||
if entity_state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
|
||||
_LOGGER.warning(
|
||||
"%s: Estimated current entity %s is not available",
|
||||
self._light_entity.entity_id,
|
||||
self._estimated_current_entity,
|
||||
)
|
||||
return None
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Estimated current %s (voltage=%d, power_factor=%.2f)",
|
||||
self._light_entity.entity_id,
|
||||
entity_state.state,
|
||||
self._voltage,
|
||||
self._power_factor,
|
||||
)
|
||||
power = float(entity_state.state) / 1000 * self._voltage * self._power_factor
|
||||
return await evaluate_power(power)
|
||||
|
||||
async def find_estimated_current_entity(self) -> str:
|
||||
entity_reg = entity_registry.async_get(self._hass)
|
||||
entity_id = f"sensor.{self._light_entity.object_id}_estimated_current"
|
||||
entry = entity_reg.async_get(entity_id)
|
||||
if entry:
|
||||
return entry.entity_id
|
||||
|
||||
if self._light_entity.entity_entry:
|
||||
entity = get_related_entity_by_device_class(self._hass, self._light_entity, SensorDeviceClass.CURRENT)
|
||||
if entity:
|
||||
return entity
|
||||
|
||||
raise StrategyConfigurationError("No estimated current entity found. Probably brightness limiter not enabled. See documentation")
|
||||
|
||||
def get_entities_to_track(self) -> list[str | TrackTemplate]:
|
||||
if self._estimated_current_entity:
|
||||
return [self._estimated_current_entity]
|
||||
return [] # pragma: no cover
|
||||
|
||||
def can_calculate_standby(self) -> bool:
|
||||
return True
|
||||
|
||||
async def validate_config(self) -> None:
|
||||
self._estimated_current_entity = await self.find_estimated_current_entity()
|
||||
Reference in New Issue
Block a user