Initil after Upgrade

This commit is contained in:
2026-06-15 10:53:52 -04:00
parent 2fe9bf0dd6
commit 887feaa50a
143 changed files with 2288 additions and 881 deletions
@@ -46,7 +46,7 @@ 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
schema[vol.Optional(CONF_ENTITY_ID)] = schema.pop(vol.Required(CONF_ENTITY_ID)) # type: ignore[index, attr-defined]
return vol.Schema(schema)
@@ -55,17 +55,17 @@ def get_numeric_state_schema() -> vol.Schema:
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
def get_state_condition_attribute_schema(value: object) -> dict[str, Any]:
"""Return the state attribute condition schema. We need to modify it to make entity_id optional."""
return make_entity_id_optional(cv.STATE_CONDITION_ATTRIBUTE_SCHEMA)(value) # type: ignore
return make_entity_id_optional(cv.STATE_CONDITION_ATTRIBUTE_SCHEMA)(value) # type: ignore[no-any-return]
def get_state_condition_state_schema(value: Any) -> dict[str, Any]: # noqa: ANN401
def get_state_condition_state_schema(value: object) -> dict[str, Any]:
"""Return the state condition schema. We need to modify it to make entity_id optional."""
return make_entity_id_optional(cv.STATE_CONDITION_STATE_SCHEMA)(value) # type: ignore
return make_entity_id_optional(cv.STATE_CONDITION_STATE_SCHEMA)(value) # type: ignore[no-any-return]
def get_state_schema(value: Any) -> dict[str, Any]: # noqa: ANN401
def get_state_schema(value: object) -> dict[str, Any]:
"""Validate a state condition."""
if not isinstance(value, dict):
raise vol.Invalid("Expected a dictionary") # pragma: no cover
@@ -158,7 +158,9 @@ class CompositeStrategy(PowerCalculationStrategyInterface):
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:
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:
@@ -186,8 +188,7 @@ class CompositeStrategy(PowerCalculationStrategyInterface):
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)
sub_strategy.strategy.set_update_callback(update_callback)
async def validate_config(self) -> None:
"""Validate correct setup of the strategy."""
@@ -204,7 +205,9 @@ class CompositeStrategy(PowerCalculationStrategyInterface):
track_templates,
)
track_entities = [entity for sub_strategy in self.strategies for entity in sub_strategy.strategy.get_entities_to_track()]
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:
@@ -171,11 +171,13 @@ class PowerCalculatorStrategyFactory:
else:
raise StrategyConfigurationError("No composite configuration supplied")
sub_strategies = composite_config
sub_strategies: list[ConfigType]
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
sub_strategies = composite_config.get(CONF_STRATEGIES, [])
else:
sub_strategies = composite_config
async def _create_sub_strategy(strategy_config: ConfigType) -> SubStrategy:
condition_instance = None
@@ -200,6 +202,8 @@ class PowerCalculatorStrategyFactory:
)
return SubStrategy(condition_config, condition_instance, strategy_instance) # type: ignore
if not sub_strategies:
raise StrategyConfigurationError("No strategies configured for composite strategy")
strategies = [await _create_sub_strategy(config) for config in sub_strategies]
return CompositeStrategy(self._hass, strategies, mode)
@@ -46,7 +46,7 @@ class FixedStrategy(PowerCalculationStrategyInterface):
if self._per_state_power is not None:
# Lookup by state
if entity_state.state in self._per_state_power:
return await evaluate_power(
return evaluate_power(
self._per_state_power.get(entity_state.state) or 0,
)
@@ -55,12 +55,12 @@ class FixedStrategy(PowerCalculationStrategyInterface):
if "|" in state_key:
attribute, value = state_key.split("|", 2)
if str(entity_state.attributes.get(attribute)) == value:
return await evaluate_power(power)
return evaluate_power(power)
if self._power is None:
return None
return await evaluate_power(self._power)
return evaluate_power(self._power)
async def validate_config(self) -> None:
"""Validate correct setup of the strategy."""
@@ -84,8 +84,10 @@ class FixedStrategy(PowerCalculationStrategyInterface):
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
track_templates.extend(
TrackTemplate(power, None, None)
for power in self._per_state_power.values()
if isinstance(power, Template)
)
return track_templates
+41 -21
View File
@@ -25,6 +25,8 @@ from custom_components.powercalc.const import (
CONF_GAMMA_CURVE,
CONF_MAX_POWER,
CONF_MIN_POWER,
CONF_POWER,
CONF_VALUE,
)
from custom_components.powercalc.errors import StrategyConfigurationError
from custom_components.powercalc.helpers import get_related_entity_by_device_class
@@ -78,6 +80,8 @@ class LinearStrategy(PowerCalculationStrategyInterface):
async def calculate(self, entity_state: State) -> Decimal | None:
"""Calculate the current power consumption."""
value_entity = self.get_initialized_value_entity()
if not self._initialized:
self._attribute = self.get_attribute(entity_state)
self._initialized = True
@@ -93,7 +97,7 @@ class LinearStrategy(PowerCalculationStrategyInterface):
_LOGGER.debug(
"%s: Linear mode state value: %d range(%d-%d)",
self._value_entity.entity_id, # type: ignore
value_entity.entity_id,
value,
min_value,
max_value,
@@ -115,9 +119,7 @@ class LinearStrategy(PowerCalculationStrategyInterface):
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
return not (self._source_entity.domain == media_player.DOMAIN and entity_state.state is not STATE_PLAYING)
def get_min_calibrate(self, value: int) -> tuple[int, float]:
"""Get closest lower value from calibration table."""
@@ -134,16 +136,19 @@ class LinearStrategy(PowerCalculationStrategyInterface):
calibrate = self._config.get(CONF_CALIBRATE)
if isinstance(calibrate, dict):
calibrate = [f"{key} -> {value}" for key, value in calibrate.items()]
elif isinstance(calibrate, list) and calibrate and isinstance(calibrate[0], dict):
calibrate = [f"{item[CONF_VALUE]} -> {item[CONF_POWER]}" for item in calibrate]
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
max_power = self._config.get(CONF_MAX_POWER)
if max_power is None: # pragma: no cover
raise StrategyConfigurationError("Linear strategy must have max power defined")
calibration_list.append((min_value, float(min_power)))
calibration_list.append(
(max_value, float(self._config.get(CONF_MAX_POWER))), # type: ignore
)
calibration_list.append((max_value, float(max_power)))
return calibration_list
for line in calibrate:
@@ -154,23 +159,30 @@ class LinearStrategy(PowerCalculationStrategyInterface):
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
if self.get_initialized_value_entity().domain == light.DOMAIN:
return 0, 255
return 0, 100
def get_initialized_value_entity(self) -> SourceEntity:
"""Return the initialized value entity."""
if self._value_entity is None: # pragma: no cover
raise StrategyConfigurationError("Linear strategy has not been initialized")
return self._value_entity
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
value_entity = self.get_initialized_value_entity()
if value_entity.entity_id is not self._source_entity.entity_id:
# 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
entity_state = self._hass.states.get(value_entity.entity_id)
if not entity_state:
_LOGGER.error(
"Value entity %s not found",
self._value_entity.entity_id, # type: ignore
value_entity.entity_id,
)
return None
@@ -184,7 +196,10 @@ class LinearStrategy(PowerCalculationStrategyInterface):
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 self._attribute is None: # pragma: no cover
return None
value = entity_state.attributes.get(self._attribute)
if value is None:
_LOGGER.warning(
"No %s attribute for entity: %s",
@@ -192,13 +207,15 @@ class LinearStrategy(PowerCalculationStrategyInterface):
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 0
return int(float(value) * 100)
value = int(value)
if self._attribute == ATTR_BRIGHTNESS and value > 255:
value = 255
return value
def get_attribute(self, entity_state: State) -> str | None:
@@ -214,9 +231,8 @@ class LinearStrategy(PowerCalculationStrategyInterface):
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),
),
"Entity domain not supported for linear mode. "
f"Must be one of: {','.join(ALLOWED_DOMAINS)}, or use the calibrate option",
"linear_unsupported_domain",
)
if CONF_MAX_POWER not in self._config:
@@ -235,7 +251,11 @@ class LinearStrategy(PowerCalculationStrategyInterface):
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:
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,
@@ -247,7 +267,7 @@ class LinearStrategy(PowerCalculationStrategyInterface):
"No battery entity found for vacuum cleaner",
"linear_no_battery_entity",
)
return await create_source_entity(related_entity, self._hass)
return create_source_entity(related_entity, self._hass)
return self._value_entity or self._source_entity
+15 -5
View File
@@ -109,7 +109,11 @@ class LutRegistry:
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()):
filenames = cast(
list[str],
await self._hass.async_add_executor_job(os.listdir, power_profile.get_model_directory()),
)
for filename in filenames:
if filename.endswith((".csv.gz", ".csv")):
base_name = filename.split(".", 1)[0]
supported_modes.add(LookupMode(base_name))
@@ -290,7 +294,8 @@ class LutStrategy(PowerCalculationStrategyInterface):
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.",
"%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
@@ -300,12 +305,17 @@ class LutStrategy(PowerCalculationStrategyInterface):
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]
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
except (KeyError, TypeError, ValueError):
_LOGGER.error(
"%s: Could not calculate power. no hue/sat set. Please check the attributes of your light in the developer tools.",
"%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
@@ -44,7 +44,8 @@ class MultiSwitchStrategy(PowerCalculationStrategyInterface):
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
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:
@@ -60,7 +61,7 @@ class MultiSwitchStrategy(PowerCalculationStrategyInterface):
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
return [*self.switch_entities]
def can_calculate_standby(self) -> bool:
return self.off_power is not None
@@ -10,8 +10,8 @@ import gzip
import logging
import os
from homeassistant.const import STATE_OFF
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_OFF
from homeassistant.core import CALLBACK_TYPE, HassJob, 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
@@ -64,6 +64,7 @@ class PlaybookStrategy(PowerCalculationStrategyInterface):
self._update_callback: Callable[[Decimal], None] = lambda power: None
self._start_time: datetime = dt.utcnow()
self._cancel_timer: CALLBACK_TYPE | None = None
self._cancel_stop_listener: CALLBACK_TYPE | None = None
self._config = config
self._repeat: bool = bool(config.get(CONF_REPEAT))
self._autostart: str | None = config.get(CONF_AUTOSTART)
@@ -116,6 +117,9 @@ class PlaybookStrategy(PowerCalculationStrategyInterface):
if self._cancel_timer is not None:
self._cancel_timer()
self._cancel_timer = None
if self._cancel_stop_listener is not None:
self._cancel_stop_listener()
self._cancel_stop_listener = None
def get_active_playbook(self) -> Playbook | None:
"""Get running playbook"""
@@ -124,41 +128,79 @@ class PlaybookStrategy(PowerCalculationStrategyInterface):
@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
self._cancel_pending_timer()
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
playbook = self._active_playbook
if self._complete_or_repeat_playbook(playbook):
return
entry = queue.dequeue()
entry = playbook.queue.dequeue()
self._schedule_playbook_entry_update(playbook, entry)
@callback
def _cancel_pending_timer(self) -> None:
if self._cancel_timer is not None:
self._cancel_timer()
self._cancel_timer = None
@callback
def _complete_or_repeat_playbook(self, playbook: Playbook) -> bool:
queue = playbook.queue
if len(queue) != 0:
return False
if self._repeat:
_LOGGER.debug("Playbook %s repeating", playbook.key)
self._start_time = dt.utcnow()
queue.reset()
self._execute_playbook_entry()
return True
_LOGGER.debug("Playbook %s completed", playbook.key)
self._active_playbook = None
return True
@callback
def _schedule_playbook_entry_update(self, playbook: Playbook, entry: PlaybookEntry) -> None:
"""Schedule the next playbook power update."""
@callback
def _update_power(date_time: datetime) -> None:
active_playbook = self._active_playbook
if active_playbook is None: # pragma: no cover
return
self._power = entry.power
_LOGGER.debug("playbook %s: Update power %.2f", self._active_playbook.key, self._power) # type: ignore
_LOGGER.debug("playbook %s: Update power %.2f", active_playbook.key, self._power)
self._update_callback(self._power)
# Schedule next update
self._execute_playbook_entry()
@callback
def _cancel_pending_updates_on_stop(_: datetime) -> None:
self._cancel_pending_timer()
if self._cancel_stop_listener is not None:
self._cancel_stop_listener()
self._cancel_stop_listener = None
self._active_playbook = None
if self._cancel_stop_listener is not None:
self._cancel_stop_listener()
self._cancel_stop_listener = self._hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP,
_cancel_pending_updates_on_stop,
)
# Schedule update in the future
self._cancel_timer = async_track_point_in_time(
self._hass,
_update_power,
HassJob(
_update_power,
name=f"powercalc playbook {playbook.key}",
cancel_on_shutdown=True,
),
self._start_time + timedelta(seconds=entry.time),
)
@@ -167,20 +209,20 @@ class PlaybookStrategy(PowerCalculationStrategyInterface):
if playbook_id in self._loaded_playbooks:
return self._loaded_playbooks[playbook_id]
playbooks: dict[str, str] = self._config.get(CONF_PLAYBOOKS) # type: ignore
playbooks: dict[str, str] = dict(self._config.get(CONF_PLAYBOOKS) or {})
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"""
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",
)
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:
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from decimal import Decimal
from homeassistant.core import HomeAssistant, State
@@ -28,5 +29,8 @@ class PowerCalculationStrategyInterface:
"""Return if this strategy is enabled based on entity state."""
return True
def set_update_callback(self, update_callback: Callable[[Decimal], None]) -> None:
"""Register update callback to allow strategy to push power updates."""
async def on_start(self, hass: HomeAssistant) -> None:
"""Called after HA has started"""
+9 -3
View File
@@ -48,7 +48,11 @@ class WledStrategy(PowerCalculationStrategyInterface):
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)
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
@@ -72,7 +76,7 @@ class WledStrategy(PowerCalculationStrategyInterface):
self._power_factor,
)
power = float(entity_state.state) / 1000 * self._voltage * self._power_factor
return await evaluate_power(power)
return evaluate_power(power)
async def find_estimated_current_entity(self) -> str:
entity_reg = entity_registry.async_get(self._hass)
@@ -86,7 +90,9 @@ class WledStrategy(PowerCalculationStrategyInterface):
if entity:
return entity
raise StrategyConfigurationError("No estimated current entity found. Probably brightness limiter not enabled. See documentation")
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: