186 files

This commit is contained in:
Home Assistant Version Control
2026-09-14 12:56:59 +00:00
parent 6be1e92941
commit bd8280defe
186 changed files with 14904 additions and 1087 deletions
@@ -12,10 +12,15 @@ import voluptuous as vol
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.const import (
CONF_CALIBRATE,
CONF_COMPOSITE,
CONF_GAMMA_CURVE,
CONF_MAX_POWER,
CONF_MIN_POWER,
CONF_MODE,
CONF_MULTI_SWITCH,
CONF_POWER,
CONF_POWER_CURVE,
CONF_POWER_OFF,
CONF_POWER_TEMPLATE,
CONF_STANDBY_POWER,
@@ -118,7 +123,7 @@ class PowerCalculatorStrategyFactory:
power_profile: PowerProfile | None,
) -> LinearStrategy:
"""Create the linear strategy."""
linear_config = self._get_strategy_config(CalculationStrategy.LINEAR, config, power_profile)
linear_config = self._get_linear_config(config, power_profile)
return LinearStrategy(
linear_config,
@@ -127,6 +132,55 @@ class PowerCalculatorStrategyFactory:
config.get(CONF_STANDBY_POWER),
)
@staticmethod
def _get_linear_config(config: ConfigType, power_profile: PowerProfile | None) -> ConfigType:
"""Combine an incomplete profile configuration with user supplied power values."""
user_config = cast(ConfigType | None, config.get(CalculationStrategy.LINEAR))
if power_profile is None or not power_profile.is_strategy_supported(CalculationStrategy.LINEAR):
if user_config is not None:
return user_config
raise StrategyConfigurationError("No linear configuration supplied")
profile_config = dict(power_profile.linear_config or {})
# Preserve the existing behavior for complete profiles: an explicitly supplied
# user configuration replaces the profile configuration entirely.
if not power_profile.needs_linear_config:
return user_config if user_config is not None else profile_config
# Smart dimmer profiles without load values can be used from YAML to measure
# only the dimmer's own consumption. Keep that behavior when the profile adds
# defaults such as gamma_curve but the user omits the linear configuration.
if not user_config:
return {
CONF_MIN_POWER: 0,
CONF_MAX_POWER: 0,
**profile_config,
}
linear_config = {
**profile_config,
**user_config,
}
PowerCalculatorStrategyFactory._remove_overridden_profile_curves(linear_config, user_config)
return linear_config
@staticmethod
def _remove_overridden_profile_curves(linear_config: ConfigType, user_config: ConfigType) -> None:
"""Remove profile curve options superseded by explicit user configuration."""
if CONF_CALIBRATE in user_config:
if CONF_GAMMA_CURVE not in user_config:
linear_config.pop(CONF_GAMMA_CURVE, None)
if CONF_POWER_CURVE not in user_config:
linear_config.pop(CONF_POWER_CURVE, None)
elif CONF_GAMMA_CURVE in user_config:
linear_config.pop(CONF_POWER_CURVE, None)
elif CONF_POWER_CURVE in user_config:
linear_config.pop(CONF_GAMMA_CURVE, None)
def _create_fixed(
self,
source_entity: SourceEntity,
+63 -12
View File
@@ -1,3 +1,4 @@
from bisect import bisect_left, bisect_right
from decimal import Decimal
import logging
from typing import Any
@@ -24,6 +25,7 @@ from custom_components.powercalc.const import (
CONF_MAX_POWER,
CONF_MIN_POWER,
CONF_POWER,
CONF_POWER_CURVE,
CONF_VALUE,
)
from custom_components.powercalc.errors import StrategyConfigurationError
@@ -41,6 +43,11 @@ CONFIG_SCHEMA = vol.Schema(
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_POWER_CURVE): vol.All(
cv.ensure_list,
[vol.Match(r"^(?:0(?:\.\d+)?|1(?:\.0+)?) -> (?:0(?:\.\d+)?|1(?:\.0+)?)$")],
vol.Length(min=2),
),
vol.Optional(CONF_ATTRIBUTE): cv.string,
},
)
@@ -71,11 +78,13 @@ class LinearStrategy(PowerCalculationStrategyInterface):
self._initialized: bool = False
self._missing_attribute_warned: bool = False
self._calibration: list[tuple[int, float]] | None = None
self._power_curve: list[tuple[float, 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()
self._power_curve = self.create_power_curve_list()
async def calculate(self, entity_state: State) -> Decimal | None:
"""Calculate the current power consumption."""
@@ -89,8 +98,7 @@ class LinearStrategy(PowerCalculationStrategyInterface):
if value is None:
return None
min_calibrate = self.get_min_calibrate(value)
max_calibrate = self.get_max_calibrate(value)
min_calibrate, max_calibrate = self.get_calibration_segment(value)
min_value = min_calibrate[0]
max_value = max_calibrate[0]
@@ -108,11 +116,8 @@ class LinearStrategy(PowerCalculationStrategyInterface):
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
power = power_range * self.apply_curve(relative_value) + min_power
return Decimal(power)
@@ -120,13 +125,22 @@ class LinearStrategy(PowerCalculationStrategyInterface):
"""Return if this strategy is enabled based on entity state."""
return not (self._source_entity.domain == media_player.DOMAIN and entity_state.state != STATE_PLAYING)
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_calibration_segment(self, value: int) -> tuple[tuple[int, float], tuple[int, float]]:
"""Get the two calibration points to interpolate between, in ascending order.
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]))
Values inside the table use the segment they fall in. Values outside it are
extrapolated along the chord between the first and the last point.
"""
calibration = self._calibration
if not calibration:
raise StrategyConfigurationError("Linear strategy has not been initialized")
if value < calibration[0][0] or value > calibration[-1][0]:
return calibration[0], calibration[-1]
index = bisect_right(calibration, value, key=lambda point: point[0])
index = min(max(index, 1), len(calibration) - 1)
return calibration[index - 1], calibration[index]
def create_calibrate_list(self) -> list[tuple[int, float]]:
"""Build a table of calibration values."""
@@ -156,6 +170,43 @@ class LinearStrategy(PowerCalculationStrategyInterface):
return sorted(calibration_list, key=lambda tup: tup[0])
def create_power_curve_list(self) -> list[tuple[float, float]] | None:
"""Build a table of normalized power curve values."""
power_curve = self._config.get(CONF_POWER_CURVE)
if not power_curve:
return None
points = []
for line in power_curve:
value, power = line.split(" -> ")
points.append((float(value), float(power)))
return sorted(points, key=lambda point: point[0])
def apply_curve(self, relative_value: float) -> float:
"""Apply a configured gamma or normalized power curve."""
gamma_curve = self._config.get(CONF_GAMMA_CURVE)
if gamma_curve:
if relative_value < 0:
# A negative base raised to a fractional exponent is complex. Below the
# calibrated range there is no curve to apply, so stay linear.
return relative_value
return float(relative_value ** float(gamma_curve))
if self._power_curve:
if relative_value <= self._power_curve[0][0]:
return self._power_curve[0][1]
if relative_value >= self._power_curve[-1][0]:
return self._power_curve[-1][1]
max_index = bisect_left(self._power_curve, relative_value, key=lambda point: point[0])
min_point = self._power_curve[max_index - 1]
max_point = self._power_curve[max_index]
value_range = max_point[0] - min_point[0]
curve_range = max_point[1] - min_point[1]
return curve_range * ((relative_value - min_point[0]) / value_range) + min_point[1]
return relative_value
def get_entity_value_range(self) -> tuple[int, int]:
"""Get the min/max range for a given entity domain."""
if self.get_initialized_value_entity().domain == light.DOMAIN:
+6 -6
View File
@@ -5,7 +5,6 @@ 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, cast
@@ -30,6 +29,7 @@ from custom_components.powercalc.errors import (
)
from custom_components.powercalc.power_profile.power_profile import PowerProfile
from .profile_data import open_profile_csv
from .strategy_interface import PowerCalculationStrategyInterface
_LOGGER = logging.getLogger(__name__)
@@ -78,7 +78,7 @@ class LutRegistry:
self._hass = hass
self._lut_entries: dict[_CacheKey, _LutEntry] = {}
self._effect_entries: dict[_CacheKey, _EffectEntry] = {}
self._supported_modes: dict[tuple[str, str, str], set[LookupMode]] = {}
self._supported_modes: dict[tuple[str, str, str | None, str], set[LookupMode]] = {}
async def get_lookup_entry(
self,
@@ -107,7 +107,7 @@ class LutRegistry:
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")
cache_key = (power_profile.manufacturer, power_profile.model, power_profile.sub_profile, "supported_modes")
supported_modes = self._supported_modes.get(cache_key)
if supported_modes is None:
supported_modes = set()
@@ -184,11 +184,11 @@ class LutRegistry:
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")
return open_profile_csv(gzip_path)
if os.path.exists(path):
_LOGGER.debug("Loading LUT data file: %s", path)
return open(path)
return open_profile_csv(path)
raise LutFileNotFoundError(f"Data file not found: {path}")
@@ -232,7 +232,7 @@ class LutStrategy(PowerCalculationStrategyInterface):
return None
effect = attrs.get(ATTR_EFFECT)
if effect and str(effect).lower() not in ("off", "none", "white"):
if effect and str(effect).lower() not in ("off", "none", "white", "default", "mode color"):
return await self._calculate_effect_power(entity_state, str(effect), brightness)
lut_mode = LookupMode.from_color_mode(color_mode)
@@ -4,7 +4,6 @@ import csv
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal
import gzip
import logging
import os
@@ -25,6 +24,7 @@ from custom_components.powercalc.const import (
)
from custom_components.powercalc.errors import StrategyConfigurationError
from .profile_data import open_profile_csv
from .strategy_interface import PowerCalculationStrategyInterface
CONFIG_SCHEMA = vol.All(
@@ -222,8 +222,7 @@ class PlaybookStrategy(PowerCalculationStrategyInterface):
f"Playbook file '{file_path}' does not exist",
)
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:
with open_profile_csv(actual_path) as csv_file:
csv_reader = csv.reader(csv_file)
entries = []
for row in csv_reader: