Initial Commit

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