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
+15 -9
View File
@@ -33,7 +33,11 @@ import voluptuous as vol
from .analytics.analytics import ANALYTICS_INTERVAL, Analytics
from .common import validate_name_pattern
from .configuration.global_config import FLAG_HAS_GLOBAL_GUI_CONFIG, get_global_configuration, get_global_gui_configuration
from .configuration.global_config import (
FLAG_HAS_GLOBAL_GUI_CONFIG,
get_global_configuration,
get_global_gui_configuration,
)
from .const import (
CONF_CREATE_DOMAIN_GROUPS,
CONF_CREATE_ENERGY_SENSORS,
@@ -190,7 +194,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
_LOGGER.critical(msg)
return False
global_config = await get_global_configuration(hass, config)
global_config = get_global_configuration(hass, config)
discovery_manager = await create_discovery_manager_instance(hass, config, global_config)
hass.data[DOMAIN] = {
@@ -205,7 +209,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
DATA_ANALYTICS: {},
}
await register_services(hass)
register_services(hass)
await async_load_platform(hass, Platform.SELECT, DOMAIN, {}, config)
await setup_yaml_sensors(hass, config, global_config)
@@ -258,7 +262,9 @@ async def create_discovery_manager_instance(
global_powercalc_config: ConfigType,
) -> DiscoveryManager:
discovery_config = global_powercalc_config.get(CONF_DISCOVERY, {})
exclude_device_types = [DeviceType(device_type) for device_type in discovery_config.get(CONF_EXCLUDE_DEVICE_TYPES, [])]
exclude_device_types = [
DeviceType(device_type) for device_type in discovery_config.get(CONF_EXCLUDE_DEVICE_TYPES, [])
]
exclude_self_usage = discovery_config.get(CONF_EXCLUDE_SELF_USAGE, False)
enable_autodiscovery = discovery_config.get(CONF_ENABLED, True)
@@ -273,7 +279,7 @@ async def create_discovery_manager_instance(
return manager
async def register_services(hass: HomeAssistant) -> None:
def register_services(hass: HomeAssistant) -> None:
"""Register generic services"""
async def _handle_change_gui_service(call: ServiceCall) -> None:
@@ -309,7 +315,7 @@ async def register_services(hass: HomeAssistant) -> None:
hass.data[DOMAIN][DATA_USED_UNIQUE_IDS] = []
hass.data[DOMAIN][DATA_CONFIGURED_ENTITIES] = {}
hass.data[DOMAIN][DATA_ANALYTICS] = {}
hass.data[DOMAIN][DOMAIN_CONFIG] = await get_global_configuration(hass, reload_config)
hass.data[DOMAIN][DOMAIN_CONFIG] = get_global_configuration(hass, reload_config)
# Reload YAML sensors if any
if DOMAIN in reload_config:
@@ -328,7 +334,7 @@ async def register_services(hass: HomeAssistant) -> None:
_LOGGER.debug("Reloading config entry %s", entry.entry_id)
await hass.config_entries.async_reload(entry.entry_id)
global_config = await get_global_configuration(hass, reload_config)
global_config = get_global_configuration(hass, reload_config)
setup_domain_groups(hass, global_config)
await create_standby_group(hass, global_config)
@@ -517,7 +523,7 @@ async def async_remove_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
sensor_type = config_entry.data.get(CONF_SENSOR_TYPE)
if sensor_type == SensorType.VIRTUAL_POWER:
updated_entries = await remove_power_sensor_from_associated_groups(
updated_entries = remove_power_sensor_from_associated_groups(
hass,
config_entry,
)
@@ -545,7 +551,7 @@ async def repair_none_config_entries_issue(hass: HomeAssistant) -> None:
try:
unique_id = f"{int(time.time() * 1000)}_{random.randint(1000, 9999)}" # noqa: S311
object.__setattr__(entry, "unique_id", unique_id)
hass.config_entries._entries._index_entry(entry) # noqa
hass.config_entries._entries._index_entry(entry) # noqa: SLF001
await hass.config_entries.async_remove(entry.entry_id)
except Exception as e: # pragma: no cover
_LOGGER.error("problem while cleaning up None entities", exc_info=e) # pragma: no cover
@@ -12,7 +12,7 @@ import uuid
import aiohttp
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import __version__ as HA_VERSION # noqa
from homeassistant.const import __version__ as HA_VERSION # noqa: N812
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -123,7 +123,7 @@ class AnalyticsCollector:
counter: Counter[Hashable] = self._data.setdefault(key, Counter()) # type:ignore
counter[value] += 1
def add(self, key: str, value: Any) -> None: # noqa: ANN401
def add(self, key: str, value: object) -> None:
"""Add value to listing"""
if self._already_seen(key) or value is None:
return
@@ -194,7 +194,11 @@ class Analytics:
cutoff = datetime(2000, 1, 1, tzinfo=UTC)
dates = chain(
(e.created_at for e in self.hass.config_entries.async_entries(DOMAIN) if e.created_at > cutoff),
(e.created_at for e in entity_registry.async_get(self.hass).entities.values() if e.domain != DOMAIN and e.created_at > cutoff),
(
e.created_at
for e in entity_registry.async_get(self.hass).entities.values()
if e.domain != DOMAIN and e.created_at > cutoff
),
)
try:
+16 -6
View File
@@ -46,7 +46,7 @@ def is_number(value: str) -> bool:
return math.isfinite(fvalue)
async def create_source_entity(entity_id: str, hass: HomeAssistant) -> SourceEntity:
def create_source_entity(entity_id: str, hass: HomeAssistant) -> SourceEntity:
"""Create object containing all information about the source entity."""
source_entity_domain, source_object_id = split_entity_id(entity_id)
@@ -61,7 +61,9 @@ async def create_source_entity(entity_id: str, hass: HomeAssistant) -> SourceEnt
entity_entry = entity_registry.async_get(entity_id)
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(entity_entry.device_id) if entity_entry and entity_entry.device_id else None
device_entry = (
device_registry.async_get(entity_entry.device_id) if entity_entry and entity_entry.device_id else None
)
unique_id = None
supported_color_modes: list[ColorMode] = []
@@ -69,13 +71,14 @@ async def create_source_entity(entity_id: str, hass: HomeAssistant) -> SourceEnt
source_entity_domain = entity_entry.domain
unique_id = entity_entry.unique_id
if entity_entry.capabilities:
supported_color_modes = entity_entry.capabilities.get( # type: ignore[assignment]
supported_color_modes = entity_entry.capabilities.get(
ATTR_SUPPORTED_COLOR_MODES,
[],
)
entity_state = hass.states.get(entity_id)
if entity_state:
supported_color_modes = entity_state.attributes.get(ATTR_SUPPORTED_COLOR_MODES)
supported_color_modes = entity_state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, [])
return SourceEntity(
source_object_id,
@@ -147,13 +150,20 @@ def get_merged_sensor_configuration(*configs: dict, validate: bool = True) -> di
CONF_CREATE_ENERGY_SENSORS,
)
is_entity_id_required = not any(key in merged_config for key in (CONF_DAILY_FIXED_ENERGY, CONF_POWER_SENSOR_ID, CONF_MULTI_SWITCH))
is_entity_id_required = not any(
key in merged_config for key in (CONF_DAILY_FIXED_ENERGY, CONF_POWER_SENSOR_ID, CONF_MULTI_SWITCH)
)
if not is_entity_id_required and CONF_ENTITY_ID not in merged_config:
merged_config[CONF_ENTITY_ID] = DUMMY_ENTITY_ID
sensor_type = merged_config.get(CONF_SENSOR_TYPE)
if validate and CONF_CREATE_GROUP not in merged_config and CONF_ENTITY_ID not in merged_config and sensor_type != SensorType.GROUP:
if (
validate
and CONF_CREATE_GROUP not in merged_config
and CONF_ENTITY_ID not in merged_config
and sensor_type != SensorType.GROUP
):
raise SensorConfigurationError(
"You must supply an entity_id in the configuration, see the README",
)
+76 -39
View File
@@ -4,8 +4,9 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
from inspect import isawaitable
import logging
from typing import Any, cast
from typing import Any
import uuid
from homeassistant.config_entries import (
@@ -21,8 +22,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_UNIQUE_ID,
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -47,7 +47,12 @@ from .const import (
)
from .errors import ModelNotSupportedError, StrategyConfigurationError
from .flow_helper.common import FlowType, PowercalcFormStep, Step, fill_schema_defaults
from .flow_helper.flows.daily_energy import SCHEMA_DAILY_ENERGY_OPTIONS, DailyEnergyConfigFlow, DailyEnergyOptionsFlow, build_daily_energy_config
from .flow_helper.flows.daily_energy import (
SCHEMA_DAILY_ENERGY_OPTIONS,
DailyEnergyConfigFlow,
DailyEnergyOptionsFlow,
build_daily_energy_config,
)
from .flow_helper.flows.global_configuration import (
GlobalConfigurationConfigFlow,
GlobalConfigurationOptionsFlow,
@@ -65,6 +70,7 @@ from .flow_helper.flows.virtual_power import (
VirtualPowerConfigFlow,
VirtualPowerOptionsFlow,
)
from .flow_helper.profile_preview import async_setup_preview as async_setup_powercalc_preview
from .flow_helper.schema import (
SCHEMA_ENERGY_SENSOR_TOGGLE,
SCHEMA_SENSOR_ENERGY_OPTIONS,
@@ -158,9 +164,14 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
super().__init__()
@staticmethod
async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up the config flow preview websocket command."""
await async_setup_powercalc_preview(hass)
@abstractmethod
@callback
def persist_config_entry(self) -> FlowResult:
def persist_config_entry(self) -> ConfigFlowResult:
pass # pragma: no cover
def _async_step(self, step: Step) -> Callable:
@@ -174,12 +185,12 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
return _async_step
async def async_step(self, step: Step, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step(self, step: Step, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle a config flow step by delegating to specific handler."""
step_method = f"async_step_{step}"
for handler in self.flow_handlers.values():
if hasattr(handler, step_method):
return await getattr(handler, step_method)(user_input) # type:ignore
return await getattr(handler, step_method)(user_input) # type: ignore[no-any-return]
raise SchemaFlowError("No handler defined") # pragma: nocover
async def validate_strategy_config(self, user_input: dict[str, Any] | None = None) -> None:
@@ -188,8 +199,14 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
self.sensor_config.get(CONF_MODE) or self.selected_profile.calculation_strategy, # type: ignore
)
factory = PowerCalculatorStrategyFactory(self.hass)
assert self.source_entity is not None
try:
await factory.create(user_input or self.sensor_config, strategy_name, self.selected_profile, self.source_entity) # type: ignore
await factory.create(
user_input or self.sensor_config,
strategy_name,
self.selected_profile,
self.source_entity,
)
except StrategyConfigurationError as error:
_LOGGER.error(str(error))
raise SchemaFlowError(error.get_config_flow_translate_key() or "unknown") from error
@@ -227,12 +244,13 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
self,
form_step: PowercalcFormStep,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the current step."""
if user_input is not None:
if form_step.validate_user_input is not None:
try:
user_input = await form_step.validate_user_input(user_input)
validated_input = form_step.validate_user_input(user_input)
user_input = await validated_input if isawaitable(validated_input) else validated_input
except SchemaFlowError as exc:
return await self._show_form(form_step, exc)
@@ -243,14 +261,15 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
self.handled_steps.append(form_step.step)
next_step = form_step.next_step
if callable(form_step.next_step):
next_step = await form_step.next_step(user_input)
resolved_next_step = form_step.next_step(user_input)
next_step = await resolved_next_step if isawaitable(resolved_next_step) else resolved_next_step
if not next_step:
return await self.handle_final_steps(
skip_advanced=not form_step.continue_advanced_step,
skip_utility_meter_options=not form_step.continue_utility_meter_options_step,
)
return await getattr(self, f"async_step_{next_step}")() # type: ignore
return await getattr(self, f"async_step_{next_step}")() # type: ignore[no-any-return]
return await self._show_form(form_step)
@@ -258,15 +277,15 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
self,
skip_advanced: bool = False,
skip_utility_meter_options: bool = False,
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the final steps of the flow if needed and persist the config entry."""
if not skip_advanced and self.selected_sensor_type == SensorType.VIRTUAL_POWER:
return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_power_advanced() # type:ignore
return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_power_advanced() # type: ignore[no-any-return]
if not skip_utility_meter_options and self.sensor_config.get(CONF_CREATE_UTILITY_METERS):
return await self.async_step_utility_meter_options()
return self.persist_config_entry()
async def _show_form(self, form_step: PowercalcFormStep, error: SchemaFlowError | None = None) -> FlowResult:
async def _show_form(self, form_step: PowercalcFormStep, error: SchemaFlowError | None = None) -> ConfigFlowResult:
# Show form for next step
last_step = None
if not callable(form_step.next_step):
@@ -288,9 +307,12 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
async def _get_schema(self, form_step: PowercalcFormStep) -> vol.Schema:
if isinstance(form_step.schema, vol.Schema):
return form_step.schema
return await form_step.schema()
schema = await form_step.schema()
if schema is None:
return vol.Schema({})
return schema
async def async_step_utility_meter_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_utility_meter_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for utility meter options."""
return await self.handle_form_step(
PowercalcFormStep(
@@ -300,7 +322,7 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
user_input,
)
async def async_step_energy_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_energy_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for utility meter options."""
return await self.handle_form_step(
PowercalcFormStep(
@@ -315,7 +337,7 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN):
"""Handle a config flow for PowerCalc."""
VERSION = 7
VERSION = 8
def __init__(self) -> None:
"""Initialize options flow."""
@@ -367,13 +389,13 @@ class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN):
self.is_library_flow = True
if discovery_info.get(CONF_MODE) == CalculationStrategy.WLED:
return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_wled() # type:ignore
return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_wled() # type: ignore[no-any-return]
if len(power_profiles) > 1:
return cast(ConfigFlowResult, await self.flow_handlers[FlowType.LIBRARY].async_step_library_multi_profile())
return await self.flow_handlers[FlowType.LIBRARY].async_step_library_multi_profile() # type: ignore[no-any-return]
self.selected_profile = power_profiles[0]
return cast(ConfigFlowResult, await self.flow_handlers[FlowType.LIBRARY].async_step_library())
return await self.flow_handlers[FlowType.LIBRARY].async_step_library() # type: ignore[no-any-return]
async def async_step_user(
self,
@@ -394,15 +416,15 @@ class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN):
return self.async_show_menu(step_id=Step.USER, menu_options=menu)
async def async_step_menu_library(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_menu_library(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the Virtual power (library) step.
We forward to the virtual_power step, but without the strategy selector displayed.
"""
self.is_library_flow = True
return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_virtual_power(user_input) # type:ignore
return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_virtual_power(user_input) # type: ignore[no-any-return]
@callback
def persist_config_entry(self) -> FlowResult:
def persist_config_entry(self) -> ConfigFlowResult:
"""Create the config entry."""
self.sensor_config.update({CONF_SENSOR_TYPE: self.selected_sensor_type})
self.sensor_config.update({CONF_NAME: self.name})
@@ -442,18 +464,21 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
async def async_step_init(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle options flow."""
self.selected_sensor_type = self.sensor_config.get(CONF_SENSOR_TYPE) or SensorType.VIRTUAL_POWER
self.source_entity_id = self.sensor_config.get(CONF_ENTITY_ID)
if self.config_entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID:
self.global_config = get_global_powercalc_config(self)
return self.async_show_menu(step_id=Step.INIT, menu_options=self.flow_handlers[FlowType.GLOBAL_CONFIGURATION].build_global_config_menu())
return self.async_show_menu(
step_id=Step.INIT,
menu_options=self.flow_handlers[FlowType.GLOBAL_CONFIGURATION].build_global_config_menu(),
)
self.sensor_config = dict(self.config_entry.data)
if self.source_entity_id:
self.source_entity = await create_source_entity(
self.source_entity = create_source_entity(
self.source_entity_id,
self.hass,
)
@@ -463,7 +488,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
return self.async_show_menu(step_id=Step.INIT, menu_options=self.build_menu())
async def initialize_library_profile(self) -> FlowResult | None:
async def initialize_library_profile(self) -> ConfigFlowResult | None:
"""Initialize the library profile, when manufacturer and model are set."""
manufacturer: str | None = self.sensor_config.get(CONF_MANUFACTURER)
model: str | None = self.sensor_config.get(CONF_MODEL)
@@ -519,19 +544,29 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
return True
async def async_step_basic_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_basic_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_options_step(user_input, self.build_basic_options_schema(), Step.BASIC_OPTIONS)
async def async_step_advanced_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_advanced_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_options_step(user_input, SCHEMA_POWER_ADVANCED, Step.ADVANCED_OPTIONS)
async def async_step_utility_meter_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_utility_meter_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_options_step(user_input, SCHEMA_UTILITY_METER_OPTIONS, Step.UTILITY_METER_OPTIONS)
return await self.async_handle_options_step(
user_input,
SCHEMA_UTILITY_METER_OPTIONS,
Step.UTILITY_METER_OPTIONS,
)
async def async_handle_options_step(self, user_input: dict[str, Any] | None, schema: vol.Schema, step: Step) -> FlowResult:
async def async_handle_options_step(
self,
user_input: dict[str, Any] | None,
schema: vol.Schema,
step: Step,
form_kwarg: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""
Generic handler for all the option steps.
processes user input against the select schema.
@@ -543,11 +578,13 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
errors = await self.process_all_options(user_input, schema)
if not errors:
return self.persist_config_entry()
return self.async_show_form(step_id=step, data_schema=schema, errors=errors)
return self.async_show_form(step_id=step, data_schema=schema, errors=errors, **(form_kwarg or {}))
def persist_config_entry(self) -> FlowResult:
def persist_config_entry(self) -> ConfigFlowResult:
"""Persist changed options on the config entry."""
data = (self.config_entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID and self.global_config) or self.sensor_config
data = (
self.config_entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID and self.global_config
) or self.sensor_config
self.hass.config_entries.async_update_entry(
self.config_entry,
@@ -597,7 +634,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
) -> None:
"""
Process the provided user input against the schema.
Update the current_config dictionary with the new options. We use that to save the data to config entry later on.
Update current_config with the new options. Used to save the data to the config entry later.
"""
for key in schema.schema:
if isinstance(key, vol.Marker):
@@ -632,7 +669,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
{vol.Optional(CONF_STANDBY_POWER): vol.Coerce(float)},
)
return schema.extend( # type: ignore
return schema.extend( # type: ignore[no-any-return]
{
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
@@ -53,7 +53,7 @@ FLAG_HAS_GLOBAL_GUI_CONFIG = "has_global_gui_config"
_LOGGER = logging.getLogger(__name__)
async def get_global_configuration(hass: HomeAssistant, config: ConfigType) -> ConfigType:
def get_global_configuration(hass: HomeAssistant, config: ConfigType) -> ConfigType:
# Default configuration values
default_config = {
CONF_ENABLE_ANALYTICS: False,
@@ -96,8 +96,8 @@ async def get_global_configuration(hass: HomeAssistant, config: ConfigType) -> C
if yaml_config:
global_config.update(yaml_config)
await handle_legacy_discovery_config(hass, global_config, yaml_config)
await handle_legacy_update_interval_config(hass, global_config, yaml_config)
handle_legacy_discovery_config(hass, global_config, yaml_config)
handle_legacy_update_interval_config(hass, global_config, yaml_config)
return global_config
+9 -2
View File
@@ -17,7 +17,7 @@ from homeassistant.const import (
EntityCategory,
)
MIN_HA_VERSION = "2025.1"
MIN_HA_VERSION = "2026.1"
BUILT_IN_LIBRARY_DIR = "powercalc_profiles"
@@ -170,6 +170,11 @@ CONF_UTILITY_METER_TARIFFS = "utility_meter_tariffs"
CONF_UTILITY_METER_TYPES = "utility_meter_types"
CONF_VALUE = "value"
CONF_VALUE_TEMPLATE = "value_template"
# Wrapper keys for ChooseSelector config flow forms (not stored in config entries).
CONF_FIXED_VALUE = "fixed_value"
CONF_DAILY_ENERGY_VALUE = "daily_energy_value"
CONF_PLAYBOOK_ID = "playbook_id"
CONF_VARIABLES = "variables"
CONF_VOLTAGE = "voltage"
CONF_WILDCARD = "wildcard"
@@ -198,7 +203,6 @@ class UnitPrefix(StrEnum):
ENTITY_CATEGORIES = [
EntityCategory.CONFIG,
EntityCategory.DIAGNOSTIC,
None,
]
@@ -225,10 +229,12 @@ API_URL = "https://api.powercalc.nl"
MANUFACTURER_WLED = "WLED"
ATTR_CALCULATION_MODE = "calculation_mode"
ATTR_MEMBERS = "members"
ATTR_ENERGY_SENSOR_ENTITY_ID = "energy_sensor_entity_id"
ATTR_ENTITIES = "entities"
ATTR_INTEGRATION = "integration"
ATTR_IS_GROUP = "is_group"
ATTR_STATE = "state"
ATTR_SOURCE_ENTITY = "source_entity"
ATTR_SOURCE_DOMAIN = "source_domain"
@@ -236,6 +242,7 @@ SERVICE_ACTIVATE_PLAYBOOK = "activate_playbook"
SERVICE_CALIBRATE_UTILITY_METER = "calibrate_utility_meter"
SERVICE_CALIBRATE_ENERGY = "calibrate_energy"
SERVICE_CHANGE_GUI_CONFIGURATION = "change_gui_config"
SERVICE_DEBUG_GROUP = "debug_group"
SERVICE_GET_ACTIVE_PLAYBOOK = "get_active_playbook"
SERVICE_GET_GROUP_ENTITIES = "get_group_entities"
SERVICE_INCREASE_DAILY_ENERGY = "increase_daily_energy"
@@ -4,7 +4,7 @@ from awesomeversion import AwesomeVersion
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE,
__version__ as HA_VERSION, # noqa
__version__ as HA_VERSION, # noqa: N812
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry
@@ -90,7 +90,11 @@ def remove_stale_devices(
)
def get_device_info(hass: HomeAssistant, sensor_config: ConfigType, source_entity: SourceEntity | None) -> DeviceInfo | None:
def get_device_info(
hass: HomeAssistant,
sensor_config: ConfigType,
source_entity: SourceEntity | None,
) -> DeviceInfo | None:
"""
Get device info for a given powercalc entity configuration.
Prefer user configured device, when it is not set fallback to the same device as the source entity
+2 -2
View File
@@ -20,7 +20,7 @@ async def async_get_config_entry_diagnostics(
data: dict = {
"entry": entry.as_dict(),
"config_entry_count_per_type": await get_count_by_sensor_type(hass),
"config_entry_count_per_type": get_count_by_sensor_type(hass),
"yaml_config": await get_yaml_configuration(hass),
}
@@ -31,7 +31,7 @@ async def async_get_config_entry_diagnostics(
return data
async def get_count_by_sensor_type(hass: HomeAssistant) -> dict[SensorType, int]:
def get_count_by_sensor_type(hass: HomeAssistant) -> dict[SensorType, int]:
count_per_type = {}
entries = get_entries_excluding_global_config(hass)
for e in entries:
+62 -24
View File
@@ -1,17 +1,17 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import timedelta
from datetime import datetime, timedelta
from enum import StrEnum
import logging
import re
from typing import Any
from typing import Any, TypeVar
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER, ConfigEntry
from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import discovery_flow
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.entity import EntityCategory
@@ -33,13 +33,22 @@ from .const import (
MANUFACTURER_WLED,
CalculationStrategy,
)
from .group_include.filter import CategoryFilter, CompositeFilter, DomainFilter, FilterOperator, LambdaFilter, NotFilter, get_filtered_entity_list
from .group_include.filter import (
CategoryFilter,
CompositeFilter,
DomainFilter,
FilterOperator,
LambdaFilter,
NotFilter,
get_filtered_entity_list,
)
from .helpers import get_or_create_unique_id
from .power_profile.factory import get_power_profile
from .power_profile.library import ModelInfo, ProfileLibrary
from .power_profile.power_profile import SUPPORTED_DOMAINS, DeviceType, DiscoveryBy, PowerProfile
_LOGGER = logging.getLogger(__name__)
_DiscoverySourceT = TypeVar("_DiscoverySourceT", er.RegistryEntry, dr.DeviceEntry)
async def get_power_profile_by_source_entity(hass: HomeAssistant, source_entity: SourceEntity) -> PowerProfile | None:
@@ -102,6 +111,7 @@ class DiscoveryManager:
self.library: ProfileLibrary | None = None
self._exclude_device_types = exclude_device_types or []
self._exclude_self_usage_profiles = exclude_self_usage_profiles or False
self._cancel_rediscover_interval: CALLBACK_TYPE | None = None
self._status = DiscoveryStatus.NOT_STARTED if enabled else DiscoveryStatus.DISABLED
async def setup(self) -> None:
@@ -112,14 +122,18 @@ class DiscoveryManager:
await self.start_discovery()
async def _rediscover(_: Any) -> None: # noqa: ANN401
async def _rediscover(_: datetime) -> None:
"""Rediscover entities."""
await self.update_library_and_rediscover()
async_track_time_interval(
if self._cancel_rediscover_interval: # pragma: no cover
self._cancel_rediscover_interval()
self._cancel_rediscover_interval = async_track_time_interval(
self.hass,
_rediscover,
timedelta(hours=2),
cancel_on_shutdown=True,
)
async def update_library_and_rediscover(self) -> None:
@@ -130,6 +144,9 @@ class DiscoveryManager:
async def start_discovery(self) -> None:
"""Start the discovery procedure."""
if self._status == DiscoveryStatus.DISABLED:
_LOGGER.debug("Discovery manager is disabled, skipping discovery run")
return
if self._status == DiscoveryStatus.IN_PROGRESS:
_LOGGER.debug("Discovery already in progress, skipping new discovery run")
return
@@ -139,10 +156,10 @@ class DiscoveryManager:
_LOGGER.debug("Start auto discovery")
_LOGGER.debug("Start entity discovery")
await self.perform_discovery(self.get_entities, self.create_entity_source, DiscoveryBy.ENTITY) # type: ignore[arg-type]
await self.perform_discovery(self.get_entities, self.create_entity_source, DiscoveryBy.ENTITY)
_LOGGER.debug("Start device discovery")
await self.perform_discovery(self.get_devices, self.create_device_source, DiscoveryBy.DEVICE) # type: ignore[arg-type]
await self.perform_discovery(self.get_devices, self.create_device_source, DiscoveryBy.DEVICE)
_LOGGER.debug("Done auto discovery")
self._status = DiscoveryStatus.FINISHED
@@ -158,7 +175,7 @@ class DiscoveryManager:
if not entity_id or entity_id == DUMMY_ENTITY_ID:
continue
entity = await create_source_entity(str(entity_id), self.hass)
entity = create_source_entity(str(entity_id), self.hass)
if entity and entity.device_entry:
self.initialized_flows.add(f"pc_{entity.device_entry.id}")
self.initialized_flows.add(entity_id)
@@ -173,13 +190,13 @@ class DiscoveryManager:
async def perform_discovery(
self,
source_provider: Callable[[], Awaitable[list]],
source_creator: Callable[[er.RegistryEntry | dr.DeviceEntry], Awaitable[SourceEntity]],
source_provider: Callable[[], Awaitable[list[_DiscoverySourceT]]],
source_creator: Callable[[_DiscoverySourceT], Awaitable[SourceEntity]],
discovery_type: DiscoveryBy,
) -> None:
"""Generalized discovery procedure for entities and devices."""
for source in await source_provider():
log_identifier = source.entity_id if discovery_type == DiscoveryBy.ENTITY else source.id
log_identifier = str(getattr(source, "entity_id", getattr(source, "id", "unknown")))
try:
model_info = await self.extract_model_info_from_device_info(source)
if not model_info:
@@ -229,7 +246,7 @@ class DiscoveryManager:
async def create_entity_source(self, entity_entry: er.RegistryEntry) -> SourceEntity:
"""Create SourceEntity for an entity."""
return await create_source_entity(entity_entry.entity_id, self.hass)
return create_source_entity(entity_entry.entity_id, self.hass)
@staticmethod
async def create_device_source(device_entry: dr.DeviceEntry) -> SourceEntity:
@@ -267,13 +284,19 @@ class DiscoveryManager:
power_profiles = []
for model_info in models:
profile = await get_power_profile(self.hass, {}, source_entity, model_info=model_info, process_variables=False)
profile = await get_power_profile(
self.hass,
{},
source_entity,
model_info=model_info,
process_variables=False,
)
if not profile or profile.discovery_by != discovery_type: # pragma: no cover
continue
if discovery_type == DiscoveryBy.ENTITY and not profile.is_entity_domain_supported(
source_entity.entity_entry, # type: ignore[arg-type]
):
continue
if discovery_type == DiscoveryBy.ENTITY:
entity_entry = source_entity.entity_entry
if entity_entry is not None and not profile.is_entity_domain_supported(entity_entry):
continue
if profile.device_type in self._exclude_device_types:
continue
if self._exclude_self_usage_profiles and profile.only_self_usage:
@@ -294,7 +317,11 @@ class DiscoveryManager:
"""Initialize the discovery flow for a WLED light."""
if DeviceType.LIGHT in self._exclude_device_types:
return
unique_id = f"pc_{source_entity.device_entry.id}" if source_entity.device_entry else get_or_create_unique_id({}, source_entity, None)
unique_id = (
f"pc_{source_entity.device_entry.id}"
if source_entity.device_entry
else get_or_create_unique_id({}, source_entity, None)
)
if self._is_already_discovered(source_entity, unique_id):
_LOGGER.debug(
"%s: Already setup with discovery, skipping new discovery (unique_id=%s)",
@@ -352,9 +379,9 @@ class DiscoveryManager:
],
FilterOperator.OR,
)
return await get_filtered_entity_list(self.hass, NotFilter(entity_filter))
return get_filtered_entity_list(self.hass, NotFilter(entity_filter))
async def get_devices(self) -> list:
async def get_devices(self) -> list[dr.DeviceEntry]:
"""Fetch device entries."""
return list(dr.async_get(self.hass).devices.values())
@@ -364,6 +391,9 @@ class DiscoveryManager:
async def disable(self) -> None:
"""Disable the discovery."""
if self._cancel_rediscover_interval:
self._cancel_rediscover_interval()
self._cancel_rediscover_interval = None
self._status = DiscoveryStatus.DISABLED
self.initialized_flows = set()
flows = self.hass.config_entries.flow.async_progress_by_handler(DOMAIN)
@@ -423,7 +453,9 @@ class DiscoveryManager:
# see https://github.com/home-assistant/core/pull/166187
manufacturer = str(device_entry.manufacturer).strip()
model = str(device_entry.model).strip()
model_id = str(device_entry.model_id).strip() if hasattr(device_entry, "model_id") and device_entry.model_id else None
model_id = (
str(device_entry.model_id).strip() if hasattr(device_entry, "model_id") and device_entry.model_id else None
)
if len(manufacturer) == 0 or len(model) == 0:
return None
@@ -508,7 +540,9 @@ class DiscoveryManager:
# Find entity ids in yaml config (Legacy)
if SENSOR_DOMAIN in self.ha_config: # pragma: no cover
sensor_config = self.ha_config.get(SENSOR_DOMAIN)
platform_entries = [item for item in sensor_config or {} if isinstance(item, dict) and item.get(CONF_PLATFORM) == DOMAIN]
platform_entries = [
item for item in sensor_config or {} if isinstance(item, dict) and item.get(CONF_PLATFORM) == DOMAIN
]
for entry in platform_entries:
entities.extend(self._find_entity_ids_in_yaml_config(entry))
@@ -521,7 +555,11 @@ class DiscoveryManager:
# Add entities from existing config entries
entities.extend(
[str(entry.data.get(CONF_ENTITY_ID)) for entry in self.hass.config_entries.async_entries(DOMAIN) if entry.source == SOURCE_USER],
[
str(entry.data.get(CONF_ENTITY_ID))
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.source == SOURCE_USER
],
)
return entities
@@ -1,4 +1,4 @@
from collections.abc import Callable, Coroutine
from collections.abc import Awaitable, Callable, Coroutine
import copy
from dataclasses import dataclass
from enum import StrEnum
@@ -59,6 +59,9 @@ class FlowType(StrEnum):
GLOBAL_CONFIGURATION = "global_configuration"
type MaybeAwaitable[R] = R | Awaitable[R]
@dataclass(slots=True)
class PowercalcFormStep:
schema: vol.Schema | Callable[[], Coroutine[Any, Any, vol.Schema | None]]
@@ -66,12 +69,12 @@ class PowercalcFormStep:
validate_user_input: (
Callable[
[dict[str, Any]],
Coroutine[Any, Any, dict[str, Any]],
MaybeAwaitable[dict[str, Any]],
]
| None
) = None
next_step: Step | Callable[[dict[str, Any]], Coroutine[Any, Any, Step | None]] | None = None
next_step: Step | Callable[[dict[str, Any]], MaybeAwaitable[Step | None]] | None = None
continue_utility_meter_options_step: bool = False
continue_advanced_step: bool = False
form_kwarg: dict[str, Any] | None = None
@@ -88,9 +91,85 @@ def fill_schema_defaults(
new_key = key
if key in options and isinstance(key, vol.Marker):
if isinstance(key, vol.Optional) and callable(key.default) and key.default():
new_key = vol.Optional(key.schema, default=options.get(key)) # type: ignore
new_key = vol.Optional(key.schema, default=options.get(key)) # type: ignore[call-overload]
elif isinstance(key, vol.Required):
new_key = vol.Required(key.schema, default=options.get(key)) # type: ignore[call-overload]
new_key.description = {"suggested_value": options.get(key)} # type: ignore[call-overload]
elif "suggested_value" not in (new_key.description or {}):
new_key = copy.copy(key)
new_key.description = {"suggested_value": options.get(key)} # type: ignore
new_key.description = {"suggested_value": options.get(key)} # type: ignore[call-overload]
schema[new_key] = val
return vol.Schema(schema)
def unwrap_choose_selector(
user_input: dict[str, Any],
wrapper_key: str,
value_key: str | Callable[[object], str] | None = None,
) -> dict[str, Any]:
"""
Unwrap a ChooseSelector value in user_input back into flat keys.
A ChooseSelector value looks like {"active_choice": "<key>", "<key>": <value>}.
The wrapper key is dropped, and the active choice's value is merged back into user_input.
Home Assistant schema validation returns the selected value directly; use ``value_key``
to map that validated value back to a config key.
"""
if wrapper_key not in user_input:
return user_input
raw = user_input.pop(wrapper_key)
if not isinstance(raw, dict):
if isinstance(value_key, str):
user_input[value_key] = raw
elif value_key is not None:
user_input[value_key(raw)] = raw
return user_input
if "active_choice" not in raw:
user_input.update(raw)
return user_input
active = raw["active_choice"]
value = raw.get(active)
if value is None:
return user_input
if isinstance(value, dict):
user_input.update(value)
else:
user_input[active] = value
return user_input
def wrap_choose_selector(
form_data: dict[str, Any],
wrapper_key: str,
choices: dict[str, list[str] | str],
*,
raw_value: bool = False,
) -> dict[str, Any]:
"""
Build the ChooseSelector value for ``wrapper_key`` from existing flat ``form_data``.
``choices`` maps the choice id to either a single key (the value of that key becomes
the choice value) or a list of keys (a dict of those keys becomes the choice value).
The first choice that has a matching key in form_data is used.
"""
for choice_id, mapping in choices.items():
keys = [mapping] if isinstance(mapping, str) else mapping
present = {key: form_data[key] for key in keys if key in form_data}
if not present:
continue
if isinstance(mapping, str):
choice_value: Any = present[mapping]
else:
choice_value = present
if raw_value:
return {**form_data, wrapper_key: choice_value}
return {**form_data, wrapper_key: {"active_choice": choice_id, choice_id: choice_value}}
return form_data
@@ -7,7 +7,11 @@ from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.power_profile.power_profile import PowerProfile
def build_dynamic_field_schema(hass: HomeAssistant, profile: PowerProfile, source_entity: SourceEntity | None) -> vol.Schema:
def build_dynamic_field_schema(
hass: HomeAssistant,
profile: PowerProfile,
source_entity: SourceEntity | None,
) -> vol.Schema:
schema = {}
for field in profile.custom_fields:
field_description = field.description
@@ -22,7 +26,8 @@ def build_dynamic_field_schema(hass: HomeAssistant, profile: PowerProfile, sourc
if "entity" in field.selector and source_entity and source_entity.device_entry:
entity_reg = er.async_get(hass)
field.selector["entity"]["include_entities"] = [
entity.entity_id for entity in entity_reg.entities.get_entries_for_device_id(source_entity.device_entry.id)
entity.entity_id
for entity in entity_reg.entities.get_entries_for_device_id(source_entity.device_entry.id)
]
schema[key] = selector(field.selector)
@@ -2,14 +2,22 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfPower, UnitOfTime
from homeassistant.data_entry_flow import FlowResult
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import (
CONF_NAME,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
UnitOfEnergy,
UnitOfPower,
UnitOfTime,
)
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
import voluptuous as vol
from custom_components.powercalc import CONF_CREATE_UTILITY_METERS
from custom_components.powercalc.const import (
CONF_DAILY_ENERGY_VALUE,
CONF_DAILY_FIXED_ENERGY,
CONF_GROUP,
CONF_ON_TIME,
@@ -18,17 +26,35 @@ from custom_components.powercalc.const import (
CONF_VALUE_TEMPLATE,
SensorType,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, fill_schema_defaults
from custom_components.powercalc.flow_helper.common import (
PowercalcFormStep,
Step,
fill_schema_defaults,
unwrap_choose_selector,
wrap_choose_selector,
)
from custom_components.powercalc.flow_helper.schema import SCHEMA_UTILITY_METER_TOGGLE
from custom_components.powercalc.sensors.daily_energy import DEFAULT_DAILY_UPDATE_FREQUENCY
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcConfigFlow, PowercalcOptionsFlow
DAILY_ENERGY_VALUE_CHOICES: dict[str, list[str] | str] = {
CONF_VALUE_TEMPLATE: CONF_VALUE_TEMPLATE,
CONF_VALUE: CONF_VALUE,
}
SCHEMA_DAILY_ENERGY_OPTIONS = vol.Schema(
{
vol.Optional(CONF_VALUE): vol.Coerce(float),
vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(),
vol.Optional(CONF_DAILY_ENERGY_VALUE): selector.ChooseSelector(
selector.ChooseSelectorConfig(
choices={
CONF_VALUE: {"selector": {"number": {"mode": "box", "step": "any"}}},
CONF_VALUE_TEMPLATE: {"selector": {"template": {}}},
},
translation_key=CONF_DAILY_ENERGY_VALUE,
),
),
vol.Optional(
CONF_UNIT_OF_MEASUREMENT,
default=UnitOfEnergy.KILO_WATT_HOUR,
@@ -58,13 +84,26 @@ SCHEMA_DAILY_ENERGY = vol.Schema(
).extend(SCHEMA_DAILY_ENERGY_OPTIONS.schema)
def daily_energy_choice_key_from_validated_value(value: object) -> str:
"""Infer the daily energy config key from a validated ChooseSelector value."""
return CONF_VALUE_TEMPLATE if isinstance(value, str) else CONF_VALUE
def build_daily_energy_config(user_input: dict[str, Any], schema: vol.Schema) -> dict[str, Any]:
"""Build the config under daily_energy: key."""
user_input = unwrap_choose_selector(
dict(user_input),
CONF_DAILY_ENERGY_VALUE,
daily_energy_choice_key_from_validated_value,
)
config: dict[str, Any] = {
CONF_DAILY_FIXED_ENERGY: {},
}
schema_keys = {key.schema if isinstance(key, vol.Marker) else key for key in schema.schema}
schema_keys.discard(CONF_DAILY_ENERGY_VALUE)
schema_keys |= {CONF_VALUE, CONF_VALUE_TEMPLATE}
for key, val in user_input.items():
if key in schema.schema and val is not None:
if key in schema_keys and val is not None:
if key in {CONF_CREATE_UTILITY_METERS, CONF_GROUP, CONF_NAME, CONF_UNIQUE_ID}:
config[str(key)] = val
continue
@@ -80,14 +119,19 @@ class DailyEnergyConfigFlow:
async def async_step_daily_energy(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the flow for daily energy sensor."""
self.flow.selected_sensor_type = SensorType.DAILY_ENERGY
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
if CONF_VALUE not in user_input and CONF_VALUE_TEMPLATE not in user_input:
def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
unwrapped = unwrap_choose_selector(
dict(user_input),
CONF_DAILY_ENERGY_VALUE,
daily_energy_choice_key_from_validated_value,
)
if CONF_VALUE not in unwrapped and CONF_VALUE_TEMPLATE not in unwrapped:
raise SchemaFlowError("daily_energy_mandatory")
return build_daily_energy_config(user_input, SCHEMA_DAILY_ENERGY)
return build_daily_energy_config(unwrapped, SCHEMA_DAILY_ENERGY)
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -104,10 +148,21 @@ class DailyEnergyOptionsFlow:
def __init__(self, flow: PowercalcOptionsFlow) -> None:
self.flow: PowercalcOptionsFlow = flow
async def async_step_daily_energy(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_daily_energy(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the daily energy options flow."""
form_data = wrap_choose_selector(
dict(self.flow.sensor_config[CONF_DAILY_FIXED_ENERGY]),
CONF_DAILY_ENERGY_VALUE,
DAILY_ENERGY_VALUE_CHOICES,
)
schema = fill_schema_defaults(
SCHEMA_DAILY_ENERGY_OPTIONS,
self.flow.sensor_config[CONF_DAILY_FIXED_ENERGY],
form_data,
)
if user_input is not None:
user_input = unwrap_choose_selector(
dict(user_input),
CONF_DAILY_ENERGY_VALUE,
daily_energy_choice_key_from_validated_value,
)
return await self.flow.async_handle_options_step(user_input, schema, Step.DAILY_ENERGY)
@@ -3,8 +3,8 @@ from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_ENABLED, CONF_SENSORS, UnitOfTime
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
@@ -44,7 +44,11 @@ from custom_components.powercalc.const import (
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step
from custom_components.powercalc.flow_helper.schema import SCHEMA_ENERGY_OPTIONS, SCHEMA_UTILITY_METER_OPTIONS, SCHEMA_UTILITY_METER_TOGGLE
from custom_components.powercalc.flow_helper.schema import (
SCHEMA_ENERGY_OPTIONS,
SCHEMA_UTILITY_METER_OPTIONS,
SCHEMA_UTILITY_METER_TOGGLE,
)
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
@@ -95,10 +99,16 @@ SCHEMA_GLOBAL_CONFIGURATION_THROTTLING = vol.Schema(
vol.Optional(CONF_ENERGY_UPDATE_INTERVAL, default=DEFAULT_ENERGY_UPDATE_INTERVAL): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
vol.Optional(CONF_GROUP_POWER_UPDATE_INTERVAL, default=DEFAULT_GROUP_POWER_UPDATE_INTERVAL): selector.NumberSelector(
vol.Optional(
CONF_GROUP_POWER_UPDATE_INTERVAL,
default=DEFAULT_GROUP_POWER_UPDATE_INTERVAL,
): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
vol.Optional(CONF_GROUP_ENERGY_UPDATE_INTERVAL, default=DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL): selector.NumberSelector(
vol.Optional(
CONF_GROUP_ENERGY_UPDATE_INTERVAL,
default=DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL,
): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
},
@@ -141,7 +151,10 @@ class GlobalConfigurationFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
async def async_step_global_configuration_discovery(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration_discovery(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the discovery configuration step."""
if user_input is not None:
@@ -162,7 +175,10 @@ class GlobalConfigurationFlow:
user_input,
)
async def async_step_global_configuration_throttling(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration_throttling(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the throttling related options."""
if user_input is not None:
@@ -184,7 +200,10 @@ class GlobalConfigurationFlow:
user_input,
)
async def async_step_global_configuration_energy(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration_energy(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the global configuration step."""
if user_input is not None:
@@ -199,11 +218,18 @@ class GlobalConfigurationFlow:
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_ENERGY,
schema=SCHEMA_GLOBAL_CONFIGURATION_ENERGY_SENSOR,
form_kwarg={"description_placeholders": {"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/"}},
form_kwarg={
"description_placeholders": {
"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/",
},
},
),
)
async def async_step_global_configuration_utility_meter(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration_utility_meter(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the global configuration step."""
if user_input is not None:
@@ -212,7 +238,7 @@ class GlobalConfigurationFlow:
return self.flow.persist_config_entry()
if not bool(self.flow.global_config.get(CONF_CREATE_UTILITY_METERS)) or user_input is not None:
return self.flow.async_create_entry( # type: ignore
return self.flow.async_create_entry(
title="Global Configuration",
data=self.flow.global_config,
)
@@ -221,7 +247,11 @@ class GlobalConfigurationFlow:
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_UTILITY_METER,
schema=SCHEMA_UTILITY_METER_OPTIONS,
form_kwarg={"description_placeholders": {"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/"}},
form_kwarg={
"description_placeholders": {
"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/",
},
},
),
)
@@ -231,7 +261,7 @@ class GlobalConfigurationConfigFlow(GlobalConfigurationFlow):
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the global configuration step."""
get_global_powercalc_config(self.flow)
await self.flow.async_set_unique_id(ENTRY_GLOBAL_CONFIG_UNIQUE_ID)
@@ -272,7 +302,7 @@ class GlobalConfigurationOptionsFlow(GlobalConfigurationFlow):
menu[Step.GLOBAL_CONFIGURATION_UTILITY_METER] = "Utility meter options"
return menu
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the global configuration step."""
if user_input is not None:
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Callable, Coroutine
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import SensorDeviceClass
@@ -15,7 +15,6 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
from homeassistant.helpers.selector import TextSelector
@@ -318,7 +317,7 @@ class GroupFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
async def async_step_assign_groups(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_assign_groups(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for assigning groups."""
group_entries = get_group_entries(self.flow.hass, GroupType.CUSTOM)
if not group_entries:
@@ -331,7 +330,7 @@ class GroupFlow:
},
)
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
groups = user_input.get(CONF_GROUP) or []
new_group = user_input.get(CONF_NEW_GROUP)
if new_group:
@@ -360,7 +359,7 @@ class GroupConfigFlow(GroupFlow):
- name: str | None
- selected_sensor_type: str | None
- async_set_unique_id(), _abort_if_unique_id_configured()
- handle_form_step(PowercalcFormStep, user_input) -> FlowResult
- handle_form_step(PowercalcFormStep, user_input) -> ConfigFlowResult
- async_show_menu(...), fill_schema_defaults(...),
- create_group_selector(...), create_schema_group_custom(...)
@@ -371,7 +370,7 @@ class GroupConfigFlow(GroupFlow):
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
async def async_step_menu_group(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
async def async_step_menu_group(self, _: dict[str, Any] | None = None) -> ConfigFlowResult:
menu = [Step.GROUP_CUSTOM, Step.GROUP_DOMAIN, Step.GROUP_SUBTRACT, Step.GROUP_TRACKED_UNTRACKED]
# Hide tracked/untracked if already present
entry = self.flow.hass.config_entries.async_entry_for_domain_unique_id(
@@ -387,9 +386,9 @@ class GroupConfigFlow(GroupFlow):
group_type: GroupType,
user_input: dict[str, Any] | None = None,
schema: vol.Schema | None = None,
next_step: Callable[[dict[str, Any]], Coroutine[Any, Any, Step | None]] | None = None,
) -> FlowResult:
async def _validate(ui: dict[str, Any]) -> dict[str, Any]:
next_step: Callable[[dict[str, Any]], Step | None] | None = None,
) -> ConfigFlowResult:
def _validate(ui: dict[str, Any]) -> dict[str, Any]:
if group_type == GroupType.CUSTOM:
validate_group_input(ui)
@@ -412,24 +411,28 @@ class GroupConfigFlow(GroupFlow):
user_input,
)
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
schema = SCHEMA_GROUP.extend(create_schema_group_custom(self.flow.hass).schema)
return await self.handle_group_step(GroupType.CUSTOM, user_input, schema)
async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
return await self.handle_group_step(GroupType.DOMAIN, user_input)
async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
return await self.handle_group_step(GroupType.SUBTRACT, user_input)
async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
await self.flow.async_set_unique_id(UNIQUE_ID_TRACKED_UNTRACKED)
self.flow.abort_if_unique_id_configured()
if user_input is not None:
user_input[CONF_NAME] = "Tracked / Untracked"
async def _next(ui: dict[str, Any]) -> Step | None:
return Step.GROUP_TRACKED_UNTRACKED_AUTO if bool(ui.get("group_tracked_auto", True)) else Step.GROUP_TRACKED_UNTRACKED_MANUAL
def _next(ui: dict[str, Any]) -> Step | None:
return (
Step.GROUP_TRACKED_UNTRACKED_AUTO
if bool(ui.get("group_tracked_auto", True))
else Step.GROUP_TRACKED_UNTRACKED_MANUAL
)
return await self.handle_group_step(
GroupType.TRACKED_UNTRACKED,
@@ -438,7 +441,10 @@ class GroupConfigFlow(GroupFlow):
next_step=_next,
)
async def async_step_group_tracked_untracked_auto(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_tracked_untracked_auto(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
schema = await create_schema_tracked_untracked_auto(self.flow.hass)
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -449,7 +455,10 @@ class GroupConfigFlow(GroupFlow):
user_input,
)
async def async_step_group_tracked_untracked_manual(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_tracked_untracked_manual(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
schema = await create_schema_group_tracked_untracked_manual(self.flow.hass, user_input)
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -468,21 +477,23 @@ class GroupOptionsFlow(GroupFlow):
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the group options flow."""
return await self.flow.async_handle_options_step(
user_input, create_schema_group_custom(self.flow.hass, self.flow.config_entry, True), Step.GROUP_CUSTOM
user_input,
create_schema_group_custom(self.flow.hass, self.flow.config_entry, True),
Step.GROUP_CUSTOM,
)
async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the group options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_GROUP_DOMAIN_OPTIONS, Step.GROUP_DOMAIN)
async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the group options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_GROUP_SUBTRACT_OPTIONS, Step.GROUP_SUBTRACT)
async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the group options flow."""
schema = SCHEMA_GROUP_TRACKED_UNTRACKED
if self.flow.sensor_config.get(CONF_GROUP_TRACKED_AUTO, True):
@@ -1,9 +1,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector, translation
import voluptuous as vol
@@ -31,10 +30,23 @@ from custom_components.powercalc.discovery import (
)
from custom_components.powercalc.flow_helper.common import FlowType, PowercalcFormStep, Step
from custom_components.powercalc.flow_helper.dynamic_field_builder import build_dynamic_field_schema
from custom_components.powercalc.flow_helper.schema import SCHEMA_ENERGY_SENSOR_TOGGLE, SCHEMA_UTILITY_METER_TOGGLE, build_sub_profile_schema
from custom_components.powercalc.helpers import collect_placeholders, iter_related_entity_placeholders, resolve_related_entity_placeholder
from custom_components.powercalc.flow_helper.schema import (
SCHEMA_ENERGY_SENSOR_TOGGLE,
SCHEMA_UTILITY_METER_TOGGLE,
build_sub_profile_schema,
)
from custom_components.powercalc.helpers import (
collect_placeholders,
iter_related_entity_placeholders,
resolve_related_entity_placeholder,
)
from custom_components.powercalc.power_profile.library import ModelInfo, ProfileLibrary
from custom_components.powercalc.power_profile.power_profile import DEVICE_TYPE_DOMAIN, DOMAIN_DEVICE_TYPE_MAPPING, DiscoveryBy, PowerProfile
from custom_components.powercalc.power_profile.power_profile import (
DEVICE_TYPE_DOMAIN,
DOMAIN_DEVICE_TYPE_MAPPING,
DiscoveryBy,
PowerProfile,
)
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
@@ -67,7 +79,7 @@ class LibraryFlow:
async def async_step_manufacturer(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Ask the user to select the manufacturer."""
async def _create_schema() -> vol.Schema:
@@ -82,7 +94,10 @@ class LibraryFlow:
]
return vol.Schema(
{
vol.Required(CONF_MANUFACTURER, default=self.flow.sensor_config.get(CONF_MANUFACTURER)): selector.SelectSelector(
vol.Required(
CONF_MANUFACTURER,
default=self.flow.sensor_config.get(CONF_MANUFACTURER),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=manufacturers,
mode=selector.SelectSelectorMode.DROPDOWN,
@@ -104,7 +119,7 @@ class LibraryFlow:
async def async_step_model(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Ask the user to select the model."""
def _build_model_label(model_id: str, model_name: str) -> str:
@@ -139,10 +154,18 @@ class LibraryFlow:
self._get_library_discovery_by(),
)
]
model = self.flow.selected_profile.model if self.flow.selected_profile else self.flow.sensor_config.get(CONF_MODEL)
model = (
self.flow.selected_profile.model
if self.flow.selected_profile
else self.flow.sensor_config.get(CONF_MODEL)
)
return vol.Schema(
{
vol.Required(CONF_MODEL, description={"suggested_value": model}, default=model): selector.SelectSelector(
vol.Required(
CONF_MODEL,
description={"suggested_value": model},
default=model,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=models,
mode=selector.SelectSelectorMode.DROPDOWN,
@@ -162,26 +185,29 @@ class LibraryFlow:
user_input,
)
async def async_step_post_library(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
async def async_step_post_library(self, _: dict[str, Any] | None = None) -> ConfigFlowResult:
"""
Handles the logic after the user either selected manufacturer/model himself or confirmed autodiscovered.
Forwards to the next step in the flow.
"""
if not self.flow.selected_profile:
return self.flow.async_abort(reason="model_not_supported") # type:ignore # pragma: no cover
return self.flow.async_abort(reason="model_not_supported") # pragma: no cover
if Step.LIBRARY_CUSTOM_FIELDS not in self.flow.handled_steps and self.flow.selected_profile.has_custom_fields:
return await self.async_step_library_custom_fields()
if Step.AVAILABILITY_ENTITY not in self.flow.handled_steps and self.flow.selected_profile.discovery_by == DiscoveryBy.DEVICE:
if (
Step.AVAILABILITY_ENTITY not in self.flow.handled_steps
and self.flow.selected_profile.discovery_by == DiscoveryBy.DEVICE
):
result = await self.async_step_availability_entity()
if result:
return result
if Step.SUB_PROFILE not in self.flow.handled_steps and await self.flow.selected_profile.requires_manual_sub_profile_selection:
if (
Step.SUB_PROFILE not in self.flow.handled_steps
and await self.flow.selected_profile.requires_manual_sub_profile_selection
):
return await self.async_step_sub_profile()
if (
@@ -191,21 +217,26 @@ class LibraryFlow:
):
return await self.async_step_smart_switch()
if Step.FIXED not in self.flow.handled_steps and self.flow.selected_profile.needs_fixed_config: # pragma: no cover
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_fixed() # type:ignore
if (
Step.FIXED not in self.flow.handled_steps and self.flow.selected_profile.needs_fixed_config
): # pragma: no cover
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_fixed() # type: ignore[no-any-return]
if Step.LINEAR not in self.flow.handled_steps and self.flow.selected_profile.needs_linear_config:
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_linear() # type:ignore
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_linear() # type: ignore[no-any-return]
if Step.MULTI_SWITCH not in self.flow.handled_steps and self.flow.selected_profile.calculation_strategy == CalculationStrategy.MULTI_SWITCH:
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_multi_switch() # type:ignore
if (
Step.MULTI_SWITCH not in self.flow.handled_steps
and self.flow.selected_profile.calculation_strategy == CalculationStrategy.MULTI_SWITCH
):
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_multi_switch() # type: ignore[no-any-return]
return await self.flow.flow_handlers[FlowType.GROUP].async_step_assign_groups() # type:ignore
return await self.flow.flow_handlers[FlowType.GROUP].async_step_assign_groups() # type: ignore[no-any-return]
async def async_step_library_custom_fields(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_library_custom_fields(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for custom fields."""
async def _process_user_input(user_input: dict[str, Any]) -> dict[str, Any]:
def _process_user_input(user_input: dict[str, Any]) -> dict[str, Any]:
return {CONF_VARIABLES: user_input}
form_kwarg: dict[str, Any] | None = None
@@ -234,11 +265,11 @@ class LibraryFlow:
async def async_step_sub_profile(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the flow for sub profile selection."""
assert self.flow.selected_profile is not None
async def _validate(user_input: dict[str, Any]) -> dict[str, str]:
def _validate(user_input: dict[str, Any]) -> dict[str, str]:
return {CONF_MODEL: f"{self.flow.sensor_config.get(CONF_MODEL)}/{user_input.get(CONF_SUB_PROFILE)}"}
library = await ProfileLibrary.factory(self.flow.hass)
@@ -254,7 +285,11 @@ class LibraryFlow:
if remarks:
remarks = "\n\n" + remarks
step = Step.SUB_PROFILE_PER_DEVICE if self.flow.selected_profile.discovery_by == DiscoveryBy.DEVICE else Step.SUB_PROFILE
step = (
Step.SUB_PROFILE_PER_DEVICE
if self.flow.selected_profile.discovery_by == DiscoveryBy.DEVICE
else Step.SUB_PROFILE
)
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -275,16 +310,16 @@ class LibraryFlow:
async def async_step_sub_profile_per_device(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
return await self.async_step_sub_profile(user_input)
async def async_step_smart_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_smart_switch(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Asks the user for the power of connect appliance for the smart switch."""
if self.flow.selected_profile and not self.flow.selected_profile.needs_fixed_config:
return self.flow.persist_config_entry()
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
return {
CONF_SELF_USAGE_INCLUDED: user_input.get(CONF_SELF_USAGE_INCLUDED),
CONF_MODE: CalculationStrategy.FIXED,
@@ -303,7 +338,7 @@ class LibraryFlow:
user_input,
)
async def async_step_availability_entity(self, user_input: dict[str, Any] | None = None) -> FlowResult | None:
async def async_step_availability_entity(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult | None:
"""Handle the flow for availability entity."""
# Auto-resolve availability entity from profile placeholders
auto_entity = self._resolve_availability_entity()
@@ -377,7 +412,7 @@ class LibraryConfigFlow(LibraryFlow):
async def async_step_library_multi_profile(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult | ConfigFlowResult:
) -> ConfigFlowResult:
"""This step gets executed when multiple profiles are found for the source entity."""
if user_input is not None:
selected_model: str = user_input.get(CONF_MODEL) # type: ignore
@@ -426,7 +461,7 @@ class LibraryConfigFlow(LibraryFlow):
async def async_step_library(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Try to autodiscover manufacturer/model first.
Ask the user to confirm this or forward to manual library selection.
"""
@@ -439,7 +474,7 @@ class LibraryConfigFlow(LibraryFlow):
return self._show_autodiscovered_profile_form()
async def _handle_library_confirmation(self, user_input: dict[str, Any]) -> FlowResult:
async def _handle_library_confirmation(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Handle the user's response to an autodiscovered library profile."""
if not user_input.get(CONF_CONFIRM_AUTODISCOVERED_MODEL) or not self.flow.selected_profile:
return await self.async_step_manufacturer()
@@ -454,27 +489,31 @@ class LibraryConfigFlow(LibraryFlow):
async def _async_autodiscover_profile(self) -> None:
"""Populate the selected profile from the source entity when possible."""
if not self.flow.source_entity or not self.flow.source_entity.entity_entry or self.flow.selected_profile is not None:
if (
not self.flow.source_entity
or not self.flow.source_entity.entity_entry
or self.flow.selected_profile is not None
):
return
self.flow.selected_profile = await get_power_profile_by_source_entity(self.flow.hass, self.flow.source_entity)
if self.flow.selected_profile is None and self.flow.source_entity.device_entry:
self.flow.selected_profile = await get_power_profile_by_source_device(self.flow.hass, self.flow.source_entity)
self.flow.selected_profile = await get_power_profile_by_source_device(
self.flow.hass,
self.flow.source_entity,
)
def _show_autodiscovered_profile_form(self) -> FlowResult:
def _show_autodiscovered_profile_form(self) -> ConfigFlowResult:
"""Show the confirmation form for an autodiscovered library profile."""
profile = self.flow.selected_profile
assert profile is not None
return cast(
FlowResult,
self.flow.async_show_form(
step_id=Step.LIBRARY,
description_placeholders=self._build_library_description_placeholders(profile),
data_schema=SCHEMA_POWER_AUTODISCOVERED,
errors={},
last_step=False,
),
return self.flow.async_show_form(
step_id=Step.LIBRARY,
description_placeholders=self._build_library_description_placeholders(profile),
data_schema=SCHEMA_POWER_AUTODISCOVERED,
errors={},
last_step=False,
)
def _build_library_description_placeholders(self, profile: PowerProfile) -> dict[str, Any]:
@@ -499,9 +538,19 @@ class LibraryConfigFlow(LibraryFlow):
def _get_profile_source(self, profile: PowerProfile) -> str:
"""Build the autodiscovery source description."""
translations = translation.async_get_cached_translations(self.flow.hass, self.flow.hass.config.language, "common", DOMAIN)
if profile.discovery_by == DiscoveryBy.DEVICE and self.flow.source_entity and self.flow.source_entity.device_entry:
return f"{translations.get(f'component.{DOMAIN}.common.source_device')}: {self.flow.source_entity.device_entry.name}"
translations = translation.async_get_cached_translations(
self.flow.hass,
self.flow.hass.config.language,
"common",
DOMAIN,
)
if (
profile.discovery_by == DiscoveryBy.DEVICE
and self.flow.source_entity
and self.flow.source_entity.device_entry
):
label = translations.get(f"component.{DOMAIN}.common.source_device")
return f"{label}: {self.flow.source_entity.device_entry.name}"
return f"{translations.get(f'component.{DOMAIN}.common.source_entity')}: {self.flow.source_entity_id}"
@@ -537,7 +586,7 @@ class LibraryOptionsFlow(LibraryFlow):
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def async_step_library_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_library_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
self.flow.is_library_flow = True
self.flow.selected_sub_profile = self.flow.selected_profile.sub_profile # type: ignore
@@ -5,8 +5,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_DEVICE, CONF_ENTITY_ID, CONF_NAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
import voluptuous as vol
@@ -39,7 +39,7 @@ class RealPowerConfigFlow:
def __init__(self, flow: PowercalcConfigFlow) -> None:
self.flow: PowercalcConfigFlow = flow
async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for real power sensor"""
self.flow.selected_sensor_type = SensorType.REAL_POWER
@@ -57,6 +57,6 @@ class RealPowerOptionsFlow:
def __init__(self, flow: PowercalcOptionsFlow) -> None:
self.flow: PowercalcOptionsFlow = flow
async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the real power options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_REAL_POWER_OPTIONS, Step.REAL_POWER)
@@ -4,8 +4,8 @@ from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITIES, CONF_ENTITY_ID, CONF_ID, CONF_NAME, CONF_PATH, Platform
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
import voluptuous as vol
@@ -17,6 +17,8 @@ from custom_components.powercalc.const import (
CONF_CALIBRATE,
CONF_CREATE_ENERGY_SENSOR,
CONF_CREATE_UTILITY_METERS,
CONF_FIXED,
CONF_FIXED_VALUE,
CONF_GAMMA_CURVE,
CONF_IGNORE_UNAVAILABLE_STATE,
CONF_MAX_POWER,
@@ -24,6 +26,7 @@ from custom_components.powercalc.const import (
CONF_MODE,
CONF_MULTIPLY_FACTOR,
CONF_MULTIPLY_FACTOR_STANDBY,
CONF_PLAYBOOK_ID,
CONF_PLAYBOOKS,
CONF_POWER,
CONF_POWER_OFF,
@@ -34,13 +37,25 @@ from custom_components.powercalc.const import (
CONF_STATE_TRIGGER,
CONF_STATES_POWER,
CONF_UNAVAILABLE_POWER,
CONF_VALUE,
DUMMY_ENTITY_ID,
CalculationStrategy,
SensorType,
)
from custom_components.powercalc.flow_helper.common import FlowType, PowercalcFormStep, Step, fill_schema_defaults
from custom_components.powercalc.flow_helper.common import (
FlowType,
PowercalcFormStep,
Step,
fill_schema_defaults,
unwrap_choose_selector,
wrap_choose_selector,
)
from custom_components.powercalc.flow_helper.flows.global_configuration import get_global_powercalc_config
from custom_components.powercalc.flow_helper.flows.library import SCHEMA_POWER_OPTIONS_LIBRARY, SCHEMA_POWER_SMART_SWITCH
from custom_components.powercalc.flow_helper.flows.library import (
SCHEMA_POWER_OPTIONS_LIBRARY,
SCHEMA_POWER_SMART_SWITCH,
)
from custom_components.powercalc.flow_helper.profile_preview import PREVIEW_NAME
from custom_components.powercalc.flow_helper.schema import (
SCHEMA_ENERGY_SENSOR_TOGGLE,
SCHEMA_SENSOR_ENERGY_OPTIONS,
@@ -76,23 +91,125 @@ SCHEMA_POWER_OPTIONS = vol.Schema(
},
)
STATES_POWER_SELECTOR = selector.ObjectSelector(
selector.ObjectSelectorConfig(
fields={
CONF_STATE: {"required": True, "selector": {"text": None}},
CONF_POWER: {"required": True, "selector": {"number": {"mode": "box", "step": "any"}}},
},
multiple=True,
label_field=CONF_STATE,
description_field=CONF_POWER,
),
)
FIXED_CHOICE_SELECTORS: dict[str, selector.ChooseSelectorChoiceConfig] = {
CONF_POWER: {"selector": {"number": {"mode": "box", "step": "any"}}},
CONF_POWER_TEMPLATE: {"selector": {"template": {}}},
CONF_STATES_POWER: {"selector": STATES_POWER_SELECTOR.serialize()["selector"]},
}
def order_choices_for_default(
choices: dict[str, selector.ChooseSelectorChoiceConfig],
default_choice: str | None,
) -> dict[str, selector.ChooseSelectorChoiceConfig]:
"""Put the default choice first because HA initializes choose selectors from the first choice."""
if default_choice not in choices:
return choices
return {
default_choice: choices[default_choice],
**{choice: config for choice, config in choices.items() if choice != default_choice},
}
def find_present_choice(form_data: dict[str, Any], choices: dict[str, list[str] | str]) -> str | None:
"""Find the first choice that has matching config data."""
for choice_id, mapping in choices.items():
keys = [mapping] if isinstance(mapping, str) else mapping
if any(key in form_data for key in keys):
return choice_id
return None
SCHEMA_POWER_FIXED = vol.Schema(
{
vol.Optional(CONF_POWER): vol.Coerce(float),
vol.Optional(CONF_POWER_TEMPLATE): selector.TemplateSelector(),
vol.Optional(CONF_STATES_POWER): selector.ObjectSelector(),
vol.Required(CONF_FIXED_VALUE): selector.ChooseSelector(
selector.ChooseSelectorConfig(
choices=FIXED_CHOICE_SELECTORS,
translation_key=CONF_FIXED_VALUE,
),
),
},
)
SCHEMA_POWER_LINEAR = 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_CALIBRATE): selector.ObjectSelector(),
vol.Optional(CONF_MIN_POWER): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX, step="any"),
),
vol.Optional(CONF_MAX_POWER): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX, step="any"),
),
vol.Optional(CONF_GAMMA_CURVE): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX, step="any"),
),
vol.Optional(CONF_CALIBRATE): selector.ObjectSelector(
selector.ObjectSelectorConfig(
fields={
CONF_VALUE: {"required": True, "selector": {"number": {"mode": "box", "step": 1}}},
CONF_POWER: {"required": True, "selector": {"number": {"mode": "box", "step": "any"}}},
},
multiple=True,
label_field=CONF_VALUE,
description_field=CONF_POWER,
),
),
},
)
FIXED_CHOICES: dict[str, list[str] | str] = {
CONF_STATES_POWER: CONF_STATES_POWER,
CONF_POWER_TEMPLATE: CONF_POWER_TEMPLATE,
CONF_POWER: CONF_POWER,
}
def fixed_choice_key_from_validated_value(value: object) -> str:
"""Infer the fixed strategy config key from a validated ChooseSelector value."""
if isinstance(value, list):
return CONF_STATES_POWER
if isinstance(value, str):
return CONF_POWER_TEMPLATE
return CONF_POWER
def unwrap_strategy_user_input(strategy: CalculationStrategy, user_input: dict[str, Any]) -> dict[str, Any]:
"""Unwrap ChooseSelector wrappers and normalize list/dict shapes for strategy user input."""
if strategy == CalculationStrategy.FIXED:
unwrap_choose_selector(user_input, CONF_FIXED_VALUE, fixed_choice_key_from_validated_value)
if CONF_STATE_TRIGGER in user_input and isinstance(user_input[CONF_STATE_TRIGGER], list):
user_input[CONF_STATE_TRIGGER] = {
item[CONF_STATE]: item[CONF_PLAYBOOK_ID] for item in user_input[CONF_STATE_TRIGGER]
}
return user_input
def wrap_strategy_form_data(strategy: CalculationStrategy, form_data: dict[str, Any]) -> dict[str, Any]:
"""Wrap flat strategy config back into ChooseSelector form structure for display."""
if strategy == CalculationStrategy.FIXED:
form_data = wrap_choose_selector(form_data, CONF_FIXED_VALUE, FIXED_CHOICES, raw_value=True)
if CONF_STATE_TRIGGER in form_data and isinstance(form_data[CONF_STATE_TRIGGER], dict):
form_data = {
**form_data,
CONF_STATE_TRIGGER: [
{CONF_STATE: state, CONF_PLAYBOOK_ID: playbook_id}
for state, playbook_id in form_data[CONF_STATE_TRIGGER].items()
],
}
return form_data
SCHEMA_POWER_MULTI_SWITCH_MANUAL = vol.Schema(
{
vol.Required(CONF_POWER): vol.Coerce(float),
@@ -127,6 +244,8 @@ STRATEGY_STEP_MAPPING: dict[CalculationStrategy, Step] = {
CalculationStrategy.WLED: Step.WLED,
}
STRATEGIES_WITHOUT_PREVIEW = {CalculationStrategy.PLAYBOOK, CalculationStrategy.MULTI_SWITCH}
class VirtualPowerFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
@@ -139,13 +258,13 @@ class VirtualPowerFlow:
create_schema_func = f"create_schema_{self.flow.strategy.lower()}"
if hasattr(self, create_schema_func):
return await getattr(self, create_schema_func)() # type: ignore
return await getattr(self, create_schema_func)() # type: ignore[no-any-return]
return STRATEGY_SCHEMAS[self.flow.strategy]
async def create_schema_linear(self) -> vol.Schema:
"""Create the config schema for linear strategy."""
return SCHEMA_POWER_LINEAR.extend( # type: ignore
return SCHEMA_POWER_LINEAR.extend( # type: ignore[no-any-return]
{
vol.Optional(CONF_ATTRIBUTE): selector.AttributeSelector(
selector.AttributeSelectorConfig(
@@ -156,6 +275,21 @@ class VirtualPowerFlow:
},
)
async def create_schema_fixed(self) -> vol.Schema:
"""Create the config schema for fixed strategy."""
fixed_config = self.flow.sensor_config.get(CONF_FIXED, {})
default_choice = find_present_choice(fixed_config, FIXED_CHOICES) if isinstance(fixed_config, dict) else None
return vol.Schema(
{
vol.Required(CONF_FIXED_VALUE): selector.ChooseSelector(
selector.ChooseSelectorConfig(
choices=order_choices_for_default(FIXED_CHOICE_SELECTORS, default_choice),
translation_key=CONF_FIXED_VALUE,
),
),
},
)
async def create_schema_multi_switch(self) -> vol.Schema:
"""Create the config schema for multi switch strategy."""
@@ -180,31 +314,57 @@ class VirtualPowerFlow:
async def create_schema_playbook(self) -> vol.Schema:
"""Create the config schema for playbook strategy."""
base_path = Path(self.flow.hass.config.path("powercalc/playbooks"))
playbook_files = [str(p.relative_to(base_path)) for p in base_path.rglob("*") if p.is_file()]
def _find_playbook_files() -> list[str]:
base_path = Path(self.flow.hass.config.path("powercalc/playbooks"))
return [str(p.relative_to(base_path)) for p in base_path.rglob("*") if p.is_file()]
playbook_files = await self.flow.hass.async_add_executor_job(_find_playbook_files)
state_trigger_state_selector: dict[str, Any] = {"text": None}
if self.flow.source_entity_id and self.flow.source_entity_id != DUMMY_ENTITY_ID:
state_trigger_state_selector = {"state": {"entity_id": self.flow.source_entity_id}}
return vol.Schema(
{
vol.Optional(CONF_PLAYBOOKS): selector.ObjectSelector(
{
"multiple": True,
"description_field": CONF_PATH,
"label_field": CONF_ID,
"fields": {
selector.ObjectSelectorConfig(
fields={
CONF_ID: {
"required": True,
"selector": {"text": None},
},
CONF_PATH: {
"required": True,
"selector": {"select": {"options": playbook_files, "mode": "dropdown", "custom_value": True}},
"selector": {
"select": {"options": playbook_files, "mode": "dropdown", "custom_value": True},
},
},
},
},
multiple=True,
description_field=CONF_PATH,
label_field=CONF_ID,
),
),
vol.Optional(CONF_REPEAT): selector.BooleanSelector(),
vol.Optional(CONF_AUTOSTART): selector.TextSelector(),
vol.Optional(CONF_STATE_TRIGGER): selector.ObjectSelector(),
vol.Optional(CONF_STATE_TRIGGER): selector.ObjectSelector(
selector.ObjectSelectorConfig(
fields={
CONF_STATE: {
"required": True,
"selector": state_trigger_state_selector,
},
CONF_PLAYBOOK_ID: {
"required": True,
"selector": {"text": None},
},
},
multiple=True,
description_field=CONF_PLAYBOOK_ID,
label_field=CONF_STATE,
),
),
},
)
@@ -213,25 +373,25 @@ class VirtualPowerFlow:
strategy: CalculationStrategy,
user_input: dict[str, Any] | None = None,
validate: Callable[[dict[str, Any]], None] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
self.flow.strategy = strategy
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
user_input = unwrap_strategy_user_input(strategy, user_input)
if validate:
validate(user_input)
# Convert states_power from dict to list to preserve order
if CONF_STATES_POWER in user_input and isinstance(user_input[CONF_STATES_POWER], dict):
user_input[CONF_STATES_POWER] = [{CONF_STATE: state, CONF_POWER: power} for state, power in user_input[CONF_STATES_POWER].items()]
await self.flow.validate_strategy_config({strategy: user_input})
return {strategy: user_input}
schema = await self.create_strategy_schema()
description_placeholders = {}
if strategy == CalculationStrategy.WLED:
description_placeholders = {
"docs_uri": "https://docs.powercalc.nl/strategies/wled/",
}
description_placeholders = {
"docs_uri": f"https://docs.powercalc.nl/strategies/{strategy.value.replace('_', '-')}/",
}
form_kwarg: dict[str, Any] = {"description_placeholders": description_placeholders}
if strategy not in STRATEGIES_WITHOUT_PREVIEW:
form_kwarg["preview"] = PREVIEW_NAME
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -239,12 +399,12 @@ class VirtualPowerFlow:
schema=schema,
next_step=Step.ASSIGN_GROUPS,
validate_user_input=_validate,
form_kwarg={"description_placeholders": description_placeholders},
form_kwarg=form_kwarg,
),
user_input,
)
async def async_step_power_advanced(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_power_advanced(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for advanced options."""
if self.flow.is_options_flow:
@@ -302,9 +462,9 @@ class VirtualPowerConfigFlow(VirtualPowerFlow):
options_schema,
get_global_powercalc_config(self.flow),
)
return schema.extend(power_options.schema) # type: ignore
return schema.extend(power_options.schema) # type: ignore[no-any-return]
async def async_step_virtual_power(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_virtual_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for virtual power sensor."""
errors: dict[str, str] = {}
@@ -313,12 +473,16 @@ class VirtualPowerConfigFlow(VirtualPowerFlow):
user_input.get(CONF_MODE) or CalculationStrategy.LUT,
)
entity_id = user_input.get(CONF_ENTITY_ID)
if selected_strategy is not CalculationStrategy.PLAYBOOK and user_input.get(CONF_NAME) is None and entity_id is None:
if (
selected_strategy is not CalculationStrategy.PLAYBOOK
and user_input.get(CONF_NAME) is None
and entity_id is None
):
errors[CONF_ENTITY_ID] = "entity_mandatory"
if not errors:
self.flow.source_entity_id = str(entity_id or DUMMY_ENTITY_ID)
self.flow.source_entity = await create_source_entity(
self.flow.source_entity = create_source_entity(
self.flow.source_entity_id,
self.flow.hass,
)
@@ -329,33 +493,30 @@ class VirtualPowerConfigFlow(VirtualPowerFlow):
return await self.forward_to_strategy_step(selected_strategy)
return self.flow.async_show_form( # type: ignore
return self.flow.async_show_form(
step_id=Step.VIRTUAL_POWER,
data_schema=self.create_schema_virtual_power(),
description_placeholders={
"doc_uri_states_power": "https://docs.powercalc.nl/strategies/fixed/#power-per-state",
},
errors=errors,
last_step=False,
)
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for fixed sensor."""
return await self.handle_strategy_step(CalculationStrategy.FIXED, user_input)
async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for fixed sensor."""
return await self.handle_strategy_step(CalculationStrategy.LINEAR, user_input)
async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for multi switch strategy."""
return await self.handle_strategy_step(CalculationStrategy.MULTI_SWITCH, user_input)
async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for WLED sensor."""
return await self.handle_strategy_step(CalculationStrategy.WLED, user_input)
async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for playbook sensor."""
def _validate(user_input: dict[str, Any]) -> None:
@@ -364,13 +525,13 @@ class VirtualPowerConfigFlow(VirtualPowerFlow):
return await self.handle_strategy_step(CalculationStrategy.PLAYBOOK, user_input, _validate)
async def forward_to_strategy_step(self, strategy: CalculationStrategy) -> FlowResult:
async def forward_to_strategy_step(self, strategy: CalculationStrategy) -> ConfigFlowResult:
"""Forward to the next step based on the selected strategy."""
step = STRATEGY_STEP_MAPPING.get(strategy)
if step is None:
return await self.flow.flow_handlers[FlowType.LIBRARY].async_step_library() # type:ignore
return await self.flow.flow_handlers[FlowType.LIBRARY].async_step_library() # type: ignore[no-any-return]
method = getattr(self.flow, f"async_step_{step}")
return await method() # type: ignore
return await method() # type: ignore[no-any-return]
class VirtualPowerOptionsFlow(VirtualPowerFlow):
@@ -383,40 +544,52 @@ class VirtualPowerOptionsFlow(VirtualPowerFlow):
user_input: dict[str, Any],
) -> dict[str, Any]:
"""Build the config dict needed for the configured strategy."""
if self.flow.strategy:
user_input = unwrap_strategy_user_input(self.flow.strategy, dict(user_input))
strategy_schema = await self.create_strategy_schema()
strategy_options: dict[str, Any] = {}
flat_keys: set[str] = set()
for key in strategy_schema.schema:
base_key = key.schema if isinstance(key, vol.Marker) else key
flat_keys.add(str(base_key))
# The wrapper-key flat values land in user_input after unwrap; collect anything
# that matches a known strategy config key.
candidate_keys = flat_keys | {
CONF_POWER,
CONF_POWER_TEMPLATE,
CONF_STATES_POWER,
CONF_MIN_POWER,
CONF_MAX_POWER,
CONF_GAMMA_CURVE,
CONF_CALIBRATE,
}
for key in candidate_keys:
if user_input.get(key) is None:
continue
strategy_options[str(key)] = user_input.get(key)
# Convert states_power from dict to list to preserve order
if CONF_STATES_POWER in strategy_options and isinstance(strategy_options[CONF_STATES_POWER], dict):
strategy_options[CONF_STATES_POWER] = [
{CONF_STATE: state, CONF_POWER: power} for state, power in strategy_options[CONF_STATES_POWER].items()
]
strategy_options[key] = user_input[key]
return strategy_options
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_handle_strategy_options_step(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_handle_strategy_options_step(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the option processing for the selected strategy."""
step = STRATEGY_STEP_MAPPING.get(self.flow.strategy or CalculationStrategy.FIXED, Step.FIXED)
@@ -429,8 +602,13 @@ class VirtualPowerOptionsFlow(VirtualPowerFlow):
**self.flow.sensor_config,
**{k: v for k, v in strategy_options.items() if k not in self.flow.sensor_config},
}
# Convert states_power from list to dict for display in ObjectSelector
if CONF_STATES_POWER in merged_options and isinstance(merged_options[CONF_STATES_POWER], list):
merged_options[CONF_STATES_POWER] = {item[CONF_STATE]: item[CONF_POWER] for item in merged_options[CONF_STATES_POWER]}
if self.flow.strategy:
merged_options = wrap_strategy_form_data(self.flow.strategy, merged_options)
schema = fill_schema_defaults(schema, merged_options)
return await self.flow.async_handle_options_step(user_input, schema, step)
form_kwarg = {"preview": PREVIEW_NAME} if self.flow.strategy not in STRATEGIES_WITHOUT_PREVIEW else None
return await self.flow.async_handle_options_step(
user_input,
schema,
step,
form_kwarg=form_kwarg,
)
@@ -0,0 +1,199 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any, Protocol, cast
from homeassistant.components import websocket_api
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import UnknownFlow
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.const import CONF_FIXED_VALUE, CalculationStrategy
from custom_components.powercalc.errors import StrategyConfigurationError, UnsupportedStrategyError
from custom_components.powercalc.flow_helper.common import unwrap_choose_selector
from custom_components.powercalc.power_profile.power_profile import PowerProfile
from custom_components.powercalc.strategy.factory import PowerCalculatorStrategyFactory
from custom_components.powercalc.strategy.selector import detect_calculation_strategy
PREVIEW_NAME = "powercalc"
PREVIEW_FRIENDLY_NAME = "Preview power"
PREVIEW_ICON = "mdi:flash"
class PreviewFlowProtocol(Protocol):
sensor_config: ConfigType
selected_profile: PowerProfile | None
source_entity: SourceEntity | None
async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up the Powercalc preview websocket command."""
websocket_api.async_register_command(hass, ws_start_preview)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{PREVIEW_NAME}/start_preview",
vol.Required("flow_id"): str,
vol.Required("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Required("user_input"): dict,
},
)
@websocket_api.async_response
async def ws_start_preview(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Generate a live Powercalc strategy preview."""
flow = _get_flow_handler(hass, msg)
flow_status = _get_flow_status(hass, msg)
errors = _validate_user_input(flow_status.get("data_schema"), msg["user_input"])
if errors:
connection.send_message(
{
"id": msg["id"],
"type": websocket_api.TYPE_RESULT,
"success": False,
"error": {"code": "invalid_user_input", "message": errors},
},
)
return
source_entity = flow.source_entity
if source_entity is None:
raise HomeAssistantError("No source entity available for Powercalc preview")
preview = await build_profile_preview(
hass,
_build_preview_sensor_config(flow, flow_status["step_id"], msg["user_input"]),
source_entity,
flow.selected_profile,
)
connection.send_result(msg["id"])
connection.send_message(
websocket_api.event_message(
msg["id"],
{
"attributes": preview["attributes"],
"state": preview["state"],
},
),
)
connection.subscriptions[msg["id"]] = lambda: None
def _get_flow_handler(hass: HomeAssistant, msg: dict[str, Any]) -> PreviewFlowProtocol:
manager = hass.config_entries.flow if msg["flow_type"] == "config_flow" else hass.config_entries.options
try:
return cast(PreviewFlowProtocol, manager._progress[msg["flow_id"]]) # noqa: SLF001
except KeyError as err:
raise UnknownFlow from err
def _get_flow_status(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str, Any]:
manager = hass.config_entries.flow if msg["flow_type"] == "config_flow" else hass.config_entries.options
return cast(dict[str, Any], manager.async_get(msg["flow_id"]))
def _validate_user_input(schema: vol.Schema | None, user_input: dict[str, Any]) -> dict[str, str]:
if schema is None:
return {}
errors: dict[str, str] = {}
key: vol.Marker
for key, validator in schema.schema.items():
if key.schema not in user_input:
continue
try:
validator(user_input[key.schema])
except vol.Invalid as ex:
errors[str(key.schema)] = str(ex.msg)
return errors
def _build_preview_sensor_config(flow: PreviewFlowProtocol, step_id: str, user_input: dict[str, Any]) -> ConfigType:
sensor_config = dict(flow.sensor_config)
try:
strategy = CalculationStrategy(step_id)
except ValueError:
return sensor_config
sensor_config[strategy] = _unwrap_preview_strategy_input(strategy, user_input)
return sensor_config
def _unwrap_preview_strategy_input(strategy: CalculationStrategy, user_input: dict[str, Any]) -> dict[str, Any]:
"""Unwrap form-only selector wrappers before building a preview strategy config."""
unwrapped = dict(user_input)
if strategy == CalculationStrategy.FIXED:
unwrap_choose_selector(unwrapped, CONF_FIXED_VALUE)
return unwrapped
async def build_profile_preview(
hass: HomeAssistant,
sensor_config: ConfigType,
source_entity: SourceEntity,
power_profile: PowerProfile | None,
) -> dict[str, Any]:
"""Build an entity-like preview containing only current calculated power."""
current_power = await _calculate_current_power(hass, sensor_config, source_entity, power_profile)
return {
"attributes": {
ATTR_FRIENDLY_NAME: PREVIEW_FRIENDLY_NAME,
ATTR_ICON: PREVIEW_ICON,
},
"state": _format_preview_state(current_power),
}
async def _calculate_current_power(
hass: HomeAssistant,
sensor_config: ConfigType,
source_entity: SourceEntity,
power_profile: PowerProfile | None,
) -> Decimal | None:
current_state = hass.states.get(source_entity.entity_id)
if current_state is None:
return None
try:
cv.template_complex(sensor_config)
except vol.Invalid:
return None
strategy = detect_calculation_strategy(sensor_config, power_profile)
try:
calculation_strategy = await PowerCalculatorStrategyFactory(hass).create(
sensor_config,
strategy,
power_profile,
source_entity,
)
except (StrategyConfigurationError, UnsupportedStrategyError):
return None
try:
return await calculation_strategy.calculate(current_state)
except HomeAssistantError:
return None
def _format_preview_state(power: Decimal | None) -> str:
if power is None:
return "unavailable"
return f"{_format_power(power)} W"
def _format_power(value: Decimal | float | str | None) -> str:
if value is None:
return "0"
decimal_value = Decimal(str(value))
return f"{decimal_value:.2f}".rstrip("0").rstrip(".")
@@ -94,7 +94,7 @@ def create_filter(
CONF_LABEL: lambda: LabelFilter(hass, filter_config), # type: ignore
CONF_WILDCARD: lambda: WildcardFilter(filter_config), # type: ignore
CONF_GROUP: lambda: GroupFilter(hass, filter_config), # type: ignore
CONF_TEMPLATE: lambda: TemplateFilter(hass, filter_config), # type: ignore
CONF_TEMPLATE: lambda: TemplateFilter(filter_config), # type: ignore
CONF_ALL: lambda: NullFilter(),
CONF_OR: lambda: create_composite_filter(filter_config, hass, FilterOperator.OR), # type: ignore
CONF_AND: lambda: create_composite_filter(filter_config, hass, FilterOperator.AND), # type: ignore
@@ -104,7 +104,7 @@ def create_filter(
return filter_mapping.get(filter_type, lambda: NullFilter())()
async def get_filtered_entity_list(
def get_filtered_entity_list(
hass: HomeAssistant,
entity_filter: EntityFilter,
) -> list[entity_registry.RegistryEntry]:
@@ -133,7 +133,11 @@ class GroupFilter(EntityFilter):
filters = []
for single_group_id in group_ids:
domain = split_entity_id(single_group_id)[0]
filter_instance = LightGroupFilter(hass, single_group_id) if domain == LIGHT_DOMAIN else StandardGroupFilter(hass, single_group_id)
filter_instance = (
LightGroupFilter(hass, single_group_id)
if domain == LIGHT_DOMAIN
else StandardGroupFilter(hass, single_group_id)
)
filters.append(filter_instance)
self.filter = CompositeFilter(filters, FilterOperator.OR) if len(filters) > 1 else filters[0]
@@ -227,8 +231,7 @@ class WildcardFilter(EntityFilter):
class TemplateFilter(EntityFilter):
def __init__(self, hass: HomeAssistant, template: Template) -> None:
template.hass = hass
def __init__(self, template: Template) -> None:
self.entity_ids = template.async_render()
def is_valid(self, entity: RegistryEntry) -> bool:
@@ -311,7 +314,9 @@ class AreaFilter(EntityFilter):
)
self.area_ids.append(area.id)
self.area_devices.update([device.id for device in device_registry.async_entries_for_area(device_reg, area.id)])
self.area_devices.update(
[device.id for device in device_registry.async_entries_for_area(device_reg, area.id)],
)
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.area_id in self.area_ids or entity.device_id in self.area_devices
@@ -350,7 +355,9 @@ class FloorFilter(EntityFilter):
self.area_ids.extend([area.id for area in areas if area.id is not None])
for area in areas:
self.devices.extend([device.id for device in device_registry.async_entries_for_area(device_reg, area.id)])
self.devices.extend(
[device.id for device in device_registry.async_entries_for_area(device_reg, area.id)],
)
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.area_id in self.area_ids or entity.device_id in self.devices
@@ -5,6 +5,7 @@ from homeassistant.components import sensor
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_registry import RegistryEntry
from custom_components.powercalc.common import create_source_entity
from custom_components.powercalc.const import (
@@ -52,7 +53,7 @@ async def find_entities(
resolved_entities: list[Entity] = []
discoverable_entities: list[str] = []
source_entities = await get_filtered_entity_list(hass, _build_filter(entity_filter))
source_entities = get_filtered_entity_list(hass, _build_filter(entity_filter))
if _LOGGER.isEnabledFor(logging.DEBUG): # pragma: no cover
_LOGGER.debug("Source entities: %s", [entity.entity_id for entity in source_entities])
@@ -70,29 +71,15 @@ async def find_entities(
resolved_entities.append(existing)
continue
is_real_sensor = False
if source_entity.domain == sensor.DOMAIN:
if source_entity.platform != DOMAIN and not include_non_powercalc:
continue
device_class = source_entity.device_class or source_entity.original_device_class
if device_class == SensorDeviceClass.POWER:
resolved_entities.append(RealPowerSensor(entity_id, source_entity.unit_of_measurement))
is_real_sensor = True
elif device_class == SensorDeviceClass.ENERGY:
resolved_entities.append(RealEnergySensor(entity_id))
is_real_sensor = True
# No need to discover a profile for something we already resolved as a real sensor
if is_real_sensor:
if _should_skip_source_entity(source_entity, include_non_powercalc):
continue
power_profile = await get_power_profile_by_source_entity(
hass,
await create_source_entity(entity_id, hass),
)
if power_profile and not await power_profile.needs_user_configuration and power_profile.is_entity_domain_supported(source_entity):
real_sensor = _create_real_sensor(source_entity)
if real_sensor:
resolved_entities.append(real_sensor)
continue
if await _is_discoverable_source_entity(hass, source_entity):
discoverable_entities.append(entity_id)
if exclude_utility_meters:
@@ -105,6 +92,34 @@ async def find_entities(
return FindEntitiesResult(resolved_entities, discoverable_entities)
def _should_skip_source_entity(source_entity: RegistryEntry, include_non_powercalc: bool) -> bool:
return source_entity.domain == sensor.DOMAIN and source_entity.platform != DOMAIN and not include_non_powercalc
def _create_real_sensor(source_entity: RegistryEntry) -> Entity | None:
if source_entity.domain != sensor.DOMAIN:
return None
device_class = source_entity.device_class or source_entity.original_device_class
if device_class == SensorDeviceClass.POWER:
return RealPowerSensor(source_entity.entity_id, source_entity.unit_of_measurement)
if device_class == SensorDeviceClass.ENERGY:
return RealEnergySensor(source_entity.entity_id)
return None # pragma: no cover
async def _is_discoverable_source_entity(hass: HomeAssistant, source_entity: RegistryEntry) -> bool:
power_profile = await get_power_profile_by_source_entity(
hass,
create_source_entity(source_entity.entity_id, hass),
)
return bool(
power_profile
and not await power_profile.needs_user_configuration
and power_profile.is_entity_domain_supported(source_entity),
)
def _build_filter(entity_filter: EntityFilter | None) -> EntityFilter:
base_filter = CompositeFilter(
[
@@ -112,7 +127,11 @@ def _build_filter(entity_filter: EntityFilter | None) -> EntityFilter:
LambdaFilter(lambda entity: entity.platform != "utility_meter"),
LambdaFilter(lambda entity: not str(entity.unique_id).startswith("powercalc_standby_group")),
LambdaFilter(lambda entity: "tracked_" not in str(entity.unique_id)),
LambdaFilter(lambda entity: entity.platform != "tasmota" or not str(entity.entity_id).endswith(("_yesterday", "_today"))),
LambdaFilter(
lambda entity: (
entity.platform != "tasmota" or not str(entity.entity_id).endswith(("_yesterday", "_today"))
),
),
],
)
if not entity_filter:
+11 -3
View File
@@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__)
PLACEHOLDER_REGEX = re.compile(r"\[\[\s*([A-Za-z_]\w*(?::[A-Za-z_]\w*)*)\s*\]\]")
async def evaluate_power(power: Template | Decimal | float) -> Decimal | None:
def evaluate_power(power: Template | Decimal | float) -> Decimal | None:
"""When power is a template render it."""
if isinstance(power, Decimal):
@@ -154,7 +154,10 @@ def collect_placeholders(data: list | str | dict[str, Any]) -> set[str]:
return found
def replace_placeholders(data: list | str | dict[str, Any], replacements: dict[str, str]) -> list | str | dict[str, Any]:
def replace_placeholders(
data: list | str | dict[str, Any],
replacements: dict[str, str],
) -> list | str | dict[str, Any]:
"""Replace placeholders in a dictionary with values from a replacement dictionary."""
if isinstance(data, dict):
for key, value in data.items():
@@ -304,7 +307,12 @@ def _get_related_entity_for_device(
if matcher(entity_entry)
]
if not related_entities:
_LOGGER.debug("No related entities found for device %s with %s %s", source_entity.device_entry.id, match_label, match_value)
_LOGGER.debug(
"No related entities found for device %s with %s %s",
source_entity.device_entry.id,
match_label,
match_value,
)
return None
return related_entities[0]
+3
View File
@@ -18,6 +18,9 @@
"get_group_entities": {
"service": "mdi:format-list-group"
},
"debug_group": {
"service": "mdi:bug-outline"
},
"increase_daily_energy": {
"service": "mdi:numeric"
},
+1 -1
View File
@@ -22,5 +22,5 @@
"requirements": [
"numpy>=1.21.1"
],
"version": "v1.20.14"
"version": "v1.21.0"
}
+60 -28
View File
@@ -1,7 +1,7 @@
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENABLED, CONF_ID, CONF_PATH
from homeassistant.const import CONF_ENABLED, CONF_ID, CONF_PATH, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.issue_registry import async_create_issue
@@ -24,6 +24,7 @@ from custom_components.powercalc.const import (
CONF_PLAYBOOK,
CONF_PLAYBOOKS,
CONF_POWER,
CONF_POWER_SENSOR_CATEGORY,
CONF_POWER_TEMPLATE,
CONF_SENSOR_TYPE,
CONF_STATE,
@@ -41,43 +42,74 @@ async def async_migrate_config_entry(hass: HomeAssistant, config_entry: ConfigEn
data = {**config_entry.data}
if version <= 1:
conf_fixed = data.get(CONF_FIXED, {})
if CONF_POWER in conf_fixed and CONF_POWER_TEMPLATE in conf_fixed:
conf_fixed.pop(CONF_POWER, None)
_migrate_power_template(data)
if version <= 2 and data.get(CONF_SENSOR_TYPE) and CONF_CREATE_ENERGY_SENSOR not in data:
data[CONF_CREATE_ENERGY_SENSOR] = True
if version <= 3:
conf_playbook = data.get(CONF_PLAYBOOK, {})
if CONF_STATES_TRIGGER in conf_playbook:
data[CONF_PLAYBOOK][CONF_STATE_TRIGGER] = conf_playbook.pop(CONF_STATES_TRIGGER)
_migrate_playbook_trigger(data)
if version <= 4 and config_entry.entry_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID:
discovery_config = {
CONF_ENABLED: data.get(CONF_ENABLE_AUTODISCOVERY_DEPRECATED, True),
CONF_EXCLUDE_DEVICE_TYPES: data.get(CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED, []),
CONF_EXCLUDE_SELF_USAGE: data.get(CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED, False),
}
data[CONF_DISCOVERY] = discovery_config
for key in [
CONF_ENABLE_AUTODISCOVERY_DEPRECATED,
CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED,
CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED,
]:
data.pop(key, None)
_migrate_global_discovery_config(data)
if version <= 5:
conf_playbook = data.get(CONF_PLAYBOOK, {})
if CONF_PLAYBOOKS in conf_playbook:
data[CONF_PLAYBOOK][CONF_PLAYBOOKS] = [{CONF_ID: key, CONF_PATH: val} for key, val in conf_playbook.pop(CONF_PLAYBOOKS).items()]
_migrate_playbooks(data)
if version <= 6:
conf_fixed = data.get(CONF_FIXED, {})
if CONF_STATES_POWER in conf_fixed and isinstance(conf_fixed[CONF_STATES_POWER], dict):
data[CONF_FIXED][CONF_STATES_POWER] = [{CONF_STATE: key, CONF_POWER: val} for key, val in conf_fixed[CONF_STATES_POWER].items()]
_migrate_states_power(data)
hass.config_entries.async_update_entry(config_entry, data=data, version=7)
if version <= 7:
_migrate_invalid_power_sensor_category(data)
hass.config_entries.async_update_entry(config_entry, data=data, version=8)
def _migrate_power_template(data: dict) -> None:
conf_fixed = data.get(CONF_FIXED, {})
if CONF_POWER in conf_fixed and CONF_POWER_TEMPLATE in conf_fixed:
conf_fixed.pop(CONF_POWER, None)
def _migrate_playbook_trigger(data: dict) -> None:
conf_playbook = data.get(CONF_PLAYBOOK, {})
if CONF_STATES_TRIGGER in conf_playbook:
data[CONF_PLAYBOOK][CONF_STATE_TRIGGER] = conf_playbook.pop(CONF_STATES_TRIGGER)
def _migrate_global_discovery_config(data: dict) -> None:
data[CONF_DISCOVERY] = {
CONF_ENABLED: data.get(CONF_ENABLE_AUTODISCOVERY_DEPRECATED, True),
CONF_EXCLUDE_DEVICE_TYPES: data.get(CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED, []),
CONF_EXCLUDE_SELF_USAGE: data.get(CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED, False),
}
for key in [
CONF_ENABLE_AUTODISCOVERY_DEPRECATED,
CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED,
CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED,
]:
data.pop(key, None)
def _migrate_playbooks(data: dict) -> None:
conf_playbook = data.get(CONF_PLAYBOOK, {})
if CONF_PLAYBOOKS in conf_playbook:
data[CONF_PLAYBOOK][CONF_PLAYBOOKS] = [
{CONF_ID: key, CONF_PATH: val} for key, val in conf_playbook.pop(CONF_PLAYBOOKS).items()
]
def _migrate_states_power(data: dict) -> None:
conf_fixed = data.get(CONF_FIXED, {})
if CONF_STATES_POWER in conf_fixed and isinstance(conf_fixed[CONF_STATES_POWER], dict):
data[CONF_FIXED][CONF_STATES_POWER] = [
{CONF_STATE: key, CONF_POWER: val} for key, val in conf_fixed[CONF_STATES_POWER].items()
]
def _migrate_invalid_power_sensor_category(data: dict) -> None:
if data.get(CONF_POWER_SENSOR_CATEGORY) == EntityCategory.CONFIG:
data.pop(CONF_POWER_SENSOR_CATEGORY)
async def async_fix_legacy_profile_config_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
@@ -113,7 +145,7 @@ async def async_fix_legacy_profile_config_entry(hass: HomeAssistant, config_entr
)
async def handle_legacy_discovery_config(hass: HomeAssistant, global_config: dict, yaml_config: dict) -> None:
def handle_legacy_discovery_config(hass: HomeAssistant, global_config: dict, yaml_config: dict) -> None:
"""Handle legacy discovery config. Might be removed in future Powercalc version"""
discovery_options = global_config.setdefault(CONF_DISCOVERY, {})
deprecated_map = {
@@ -150,7 +182,7 @@ async def handle_legacy_discovery_config(hass: HomeAssistant, global_config: dic
)
async def handle_legacy_update_interval_config(hass: HomeAssistant, global_config: dict, yaml_config: dict) -> None:
def handle_legacy_update_interval_config(hass: HomeAssistant, global_config: dict, yaml_config: dict) -> None:
"""Handle legacy group update interval config. Might be removed in future Powercalc version"""
has_legacy_config = False
@@ -141,7 +141,13 @@ class ProfileLibrary:
raise LibraryError(f"Model {model_info.manufacturer} {model_info.model} not found")
model_info = next(iter(models))
profile = await self.create_power_profile(model_info, source_entity, custom_directory, variables, process_variables)
profile = await self.create_power_profile(
model_info,
source_entity,
custom_directory,
variables,
process_variables,
)
if sub_profile:
await profile.select_sub_profile(sub_profile)
@@ -163,7 +169,11 @@ class ProfileLibrary:
if linked_profile := json_data.get("linked_profile", json_data.get("linked_lut")):
linked_manufacturer, linked_model = linked_profile.split("/")
linked_json_data, directory = await self._load_model_data(linked_manufacturer, linked_model, custom_directory)
linked_json_data, directory = await self._load_model_data(
linked_manufacturer,
linked_model,
custom_directory,
)
json_data.update(linked_json_data)
raw_sub_profiles = await self._hass.async_add_executor_job(load_sub_profile_data, directory)
@@ -202,7 +212,12 @@ class ProfileLibrary:
replacements = self.compute_replacement_variables(placeholders, variables.copy(), source_entity)
return cast(dict[str, Any], replace_placeholders(json_data, replacements))
def compute_replacement_variables(self, placeholders: set[str], variables: dict[str, str], source_entity: SourceEntity | None) -> dict[str, str]:
def compute_replacement_variables(
self,
placeholders: set[str],
variables: dict[str, str],
source_entity: SourceEntity | None,
) -> dict[str, str]:
variables = variables or {}
if source_entity:
@@ -216,7 +231,9 @@ class ProfileLibrary:
source_entity=source_entity,
)
if not related_entity:
raise LibraryError(build_related_entity_placeholder_not_found_message(placeholder, source_entity.entity_id))
raise LibraryError(
build_related_entity_placeholder_not_found_message(placeholder, source_entity.entity_id),
)
variables[placeholder] = related_entity
return variables
@@ -286,7 +303,9 @@ class ProfileLibrary:
async def _load_model_data(self, manufacturer: str, model: str, custom_directory: str | None) -> tuple[dict, str]:
"""Load the model data from the appropriate directory."""
loader = LocalLoader(self._hass, custom_directory, is_custom_directory=True) if custom_directory else self._loader
loader = (
LocalLoader(self._hass, custom_directory, is_custom_directory=True) if custom_directory else self._loader
)
result = await loader.load_model(manufacturer, model)
if not result:
raise LibraryError(f"Model {manufacturer} {model} not found")
@@ -11,7 +11,8 @@ class CompositeLoader(Loader):
self.loaders = loaders
async def initialize(self) -> None:
[await loader.initialize() for loader in self.loaders] # type: ignore[func-returns-value]
for loader in self.loaders:
await loader.initialize()
async def get_manufacturer_listing(
self,
@@ -20,7 +21,11 @@ class CompositeLoader(Loader):
) -> set[tuple[str, str]]:
"""Get listing of available manufacturers."""
return {manufacturer for loader in self.loaders for manufacturer in await loader.get_manufacturer_listing(device_types, discovery_by)}
return {
manufacturer
for loader in self.loaders
for manufacturer in await loader.get_manufacturer_listing(device_types, discovery_by)
}
async def find_manufacturers(self, search: str) -> set[str]:
"""Check if a manufacturer is available. Also must check aliases."""
@@ -42,7 +47,11 @@ class CompositeLoader(Loader):
) -> set[tuple[str, str]]:
"""Get listing of available models and display names for a given manufacturer."""
return {model for loader in self.loaders for model in await loader.get_model_listing(manufacturer, device_types, discovery_by)}
return {
model
for loader in self.loaders
for model in await loader.get_model_listing(manufacturer, device_types, discovery_by)
}
async def load_model(self, manufacturer: str, model: str) -> tuple[dict, str] | None:
for loader in self.loaders:
@@ -1,3 +1,4 @@
from functools import partial
import json
import logging
import os
@@ -101,12 +102,9 @@ class LocalLoader(Loader):
_model = model.lower()
if self._is_custom_directory:
model_path = os.path.join(self._data_directory)
model_json_path = os.path.join(model_path, "model.json")
if not os.path.exists(model_json_path):
raise LibraryLoadingError(f"model.json not found for manufacturer {_manufacturer} " + f"and model {_model} in path {model_json_path}")
model_json = await self._hass.async_add_executor_job(self._load_json, model_json_path)
model_path, model_json = await self._hass.async_add_executor_job(
partial(self._load_custom_model, _manufacturer, _model),
)
return model_json, model_path
lib_models = self._manufacturer_model_listing.get(_manufacturer)
@@ -205,6 +203,17 @@ class LocalLoader(Loader):
self._manufacturer_model_listing[manufacturer].update({search_key: profile})
def _load_custom_model(self, manufacturer: str, model: str) -> tuple[str, dict[str, Any]]:
"""Load model.json from a directly configured custom model directory."""
model_path = os.path.join(self._data_directory)
model_json_path = os.path.join(model_path, "model.json")
if not os.path.exists(model_json_path):
raise LibraryLoadingError(
f"model.json not found for manufacturer {manufacturer} and model {model} in path {model_json_path}",
)
return model_path, self._load_json(model_json_path)
def _load_json(self, model_json_path: str) -> dict[str, Any]:
"""Load model.json file for a given model."""
with open(model_json_path) as file:
@@ -64,7 +64,7 @@ class RemoteLoader(Loader):
"""Initialize the loader."""
integration = await async_get_integration(self.hass, DOMAIN)
powercalc_version = AwesomeVersion(integration.version)
powercalc_version = AwesomeVersion(str(integration.version))
self.library_contents = await self.load_library_json()
self.profile_hashes = await self.hass.async_add_executor_job(self._load_profile_hashes)
@@ -138,7 +138,7 @@ class RemoteLoader(Loader):
async def _download_remote_library_json() -> dict[str, Any] | None:
"""
Download library.json from Github.
If download is successful, save it to local storage to use as fallback in case of internet connection issues.
On success, save it to local storage as a fallback for internet connection issues.
"""
_LOGGER.debug("Loading library.json from github")
@@ -182,7 +182,10 @@ class RemoteLoader(Loader):
return {
(manufacturer["dir_name"], manufacturer["full_name"])
for manufacturer in self.library_contents.get("manufacturers", [])
if any(self._model_matches_filters(model, device_types, discovery_by) for model in manufacturer.get("models", []))
if any(
self._model_matches_filters(model, device_types, discovery_by)
for model in manufacturer.get("models", [])
)
}
@async_cache
@@ -225,7 +228,12 @@ class RemoteLoader(Loader):
async def find_model(self, manufacturer: str, search: set[str]) -> list[str]:
"""Find matching model IDs in the library."""
models = self.model_lookup.get(manufacturer, {})
return [model["id"] for phrase in search if (phrase_lower := phrase.lower()) in models for model in models[phrase_lower]]
return [
model["id"]
for phrase in search
if (phrase_lower := phrase.lower()) in models
for model in models[phrase_lower]
]
@async_cache
async def find_model_migration(self, manufacturer: str, model: str) -> str | None:
@@ -274,12 +282,19 @@ class RemoteLoader(Loader):
raise LibraryLoadingError("Model not found in library: %s/%s", manufacturer, model)
return model_info
async def _needs_update(self, model_info: LibraryModel, manufacturer: str, model: str, model_path: str, force_update: bool) -> bool:
async def _needs_update(
self,
model_info: LibraryModel,
manufacturer: str,
model: str,
model_path: str,
force_update: bool,
) -> bool:
"""Check if the model needs to be updated."""
if force_update:
return True
path_exists = os.path.exists(model_path)
path_exists = await self.hass.async_add_executor_job(os.path.exists, model_path)
if not path_exists:
return True
@@ -287,7 +302,13 @@ class RemoteLoader(Loader):
new_hash = model_info.get("hash")
return existing_hash != new_hash
async def _download_profile_with_retry(self, manufacturer: str, model: str, storage_path: str, model_path: str) -> None:
async def _download_profile_with_retry(
self,
manufacturer: str,
model: str,
storage_path: str,
model_path: str,
) -> None:
"""Attempt to download the profile, with retry logic and error handling."""
try:
model_info = self._get_library_model(manufacturer, model)
@@ -297,12 +318,22 @@ class RemoteLoader(Loader):
self.profile_hashes[f"{manufacturer}/{model}"] = model_hash
await self.hass.async_add_executor_job(self._write_profile_hashes, self.profile_hashes)
except ProfileDownloadError as e:
if not os.path.exists(model_path):
if os.path.exists(storage_path):
path_exists, storage_path_exists = await self.hass.async_add_executor_job(
self._profile_paths_exist,
model_path,
storage_path,
)
if not path_exists:
if storage_path_exists:
await self.hass.async_add_executor_job(shutil.rmtree, storage_path) # pragma: no cover
raise e
_LOGGER.debug("Failed to download profile, falling back to local profile")
@staticmethod
def _profile_paths_exist(model_path: str, storage_path: str) -> tuple[bool, bool]:
"""Check profile paths from the executor."""
return os.path.exists(model_path), os.path.exists(storage_path)
async def _load_model_json(self, model_path: str) -> dict:
"""Load the JSON data from the model file."""
@@ -330,7 +361,10 @@ class RemoteLoader(Loader):
"""Retrieve the storage path for a given manufacturer and model."""
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, manufacturer, model))
async def download_with_retry(self, callback: Callable[[], Coroutine[Any, Any, None | dict[str, Any]]]) -> None | dict[str, Any]:
async def download_with_retry(
self,
callback: Callable[[], Coroutine[Any, Any, None | dict[str, Any]]],
) -> None | dict[str, Any]:
"""Download a file from a remote endpoint with retries"""
max_retries = 3
retry_count = 0
@@ -342,7 +376,9 @@ class RemoteLoader(Loader):
_LOGGER.debug(e)
retry_count += 1
if retry_count == max_retries:
raise ProfileDownloadError(f"Failed to download even after {max_retries} retries, falling back to local copy") from e
raise ProfileDownloadError(
f"Failed to download even after {max_retries} retries, falling back to local copy",
) from e
await asyncio.sleep(self.retry_timeout)
_LOGGER.warning("Failed to download, retrying... (Attempt %d of %d)", retry_count + 1, max_retries)
@@ -396,7 +432,7 @@ class RemoteLoader(Loader):
return {}
with open(path) as f:
return json.load(f) # type: ignore
return json.load(f) # type: ignore[no-any-return]
def _write_profile_hashes(self, hashes: dict[str, str]) -> None:
"""Write profile hashes to local storage"""
@@ -95,7 +95,9 @@ DEVICE_TYPE_DOMAIN: dict[DeviceType, str | set[str]] = {
DeviceType.UPS: SENSOR_DOMAIN,
}
SUPPORTED_DOMAINS: set[str] = {domain for domains in DEVICE_TYPE_DOMAIN.values() for domain in (domains if isinstance(domains, set) else {domains})}
SUPPORTED_DOMAINS: set[str] = {
domain for domains in DEVICE_TYPE_DOMAIN.values() for domain in (domains if isinstance(domains, set) else {domains})
}
def _build_domain_device_type_mapping() -> Mapping[str, set[DeviceType]]:
@@ -393,7 +395,8 @@ class PowerProfile:
if found_profile is None:
raise ModelNotSupportedError(
f"Sub profile not found (manufacturer: {self._manufacturer}, model: {self._model}, sub_profile: {sub_profile})",
f"Sub profile not found (manufacturer: {self._manufacturer}, "
f"model: {self._model}, sub_profile: {sub_profile})",
)
self._sub_profile_dir = os.path.join(self._directory, sub_profile)
@@ -2,7 +2,7 @@ from __future__ import annotations
from enum import StrEnum
import re
from typing import Any, NamedTuple, Protocol
from typing import NamedTuple, Protocol
from homeassistant.core import HomeAssistant, State
@@ -79,7 +79,13 @@ class SubProfileSelectConfig(NamedTuple):
class SubProfileMatcher(Protocol):
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> SubProfileMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> SubProfileMatcher:
"""Create a matcher from a config dict."""
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
@@ -114,8 +120,15 @@ class EntityStateMatcher(SubProfileMatcher):
return self._mapping.get(state.state)
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityStateMatcher: # noqa: ANN401
return cls(kwargs["hass"], kwargs["source_entity"], config["entity_id"], config["map"])
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> EntityStateMatcher:
assert hass is not None
return cls(hass, source_entity, config["entity_id"], config["map"])
def get_tracking_entities(self) -> list[str]:
return [self._entity_id]
@@ -134,7 +147,13 @@ class AttributeMatcher(SubProfileMatcher):
return self._mapping.get(val)
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> AttributeMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> AttributeMatcher:
return cls(config["attribute"], config["map"])
def get_tracking_entities(self) -> list[str]:
@@ -153,7 +172,13 @@ class EntityIdMatcher(SubProfileMatcher):
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityIdMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> EntityIdMatcher:
return cls(config["pattern"], config["profile"])
def get_tracking_entities(self) -> list[str]:
@@ -176,7 +201,13 @@ class IntegrationMatcher(SubProfileMatcher):
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> IntegrationMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> IntegrationMatcher:
return cls(config["integration"], config["profile"])
def get_tracking_entities(self) -> list[str]:
@@ -204,7 +235,13 @@ class EntityRegistryMatcher(SubProfileMatcher):
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityRegistryMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> EntityRegistryMatcher:
return cls(config["property"], config["value"], config["profile"])
def get_tracking_entities(self) -> list[str]:
@@ -236,7 +273,13 @@ class ModelIdMatcher(SubProfileMatcher):
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> ModelIdMatcher: # noqa: ANN401
def from_config(
cls,
config: dict,
*,
hass: HomeAssistant | None = None,
source_entity: SourceEntity | None = None,
) -> ModelIdMatcher:
return cls(config["model_id"], config["profile"])
def get_tracking_entities(self) -> list[str]:
+5 -8
View File
@@ -19,10 +19,7 @@ class SubProfileRepairFlow(RepairsFlow):
self._config_entry = config_entry
self._hass = hass
async def async_step_init(
self,
user_input: dict[str, str] | None = None,
) -> data_entry_flow.FlowResult:
async def async_step_init(self, _: dict[str, str] | None = None) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_sub_profile()
@@ -32,9 +29,9 @@ class SubProfileRepairFlow(RepairsFlow):
new_data = self._config_entry.data.copy()
new_data[CONF_MODEL] = f"{new_data[CONF_MODEL]}/{user_input[CONF_SUB_PROFILE]}"
self._hass.config_entries.async_update_entry(self._config_entry, data=new_data)
return self.async_create_entry(title="", data={})
return self.async_create_entry(title="", data={}) # type: ignore[no-any-return]
source_entity = await create_source_entity(self._config_entry.data[CONF_ENTITY_ID], self._hass)
source_entity = create_source_entity(self._config_entry.data[CONF_ENTITY_ID], self._hass)
profile = await get_power_profile(self.hass, dict(self._config_entry.data), source_entity)
assert profile
sub_profile_schema = await build_sub_profile_schema(profile, None)
@@ -43,7 +40,7 @@ class SubProfileRepairFlow(RepairsFlow):
if remarks:
remarks = "\n\n" + remarks
return self.async_show_form(
return self.async_show_form( # type: ignore[no-any-return]
step_id="sub_profile",
data_schema=sub_profile_schema,
description_placeholders={
@@ -57,7 +54,7 @@ class SubProfileRepairFlow(RepairsFlow):
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
_: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
+2 -2
View File
@@ -47,9 +47,9 @@ def delayed_add_entities_handler(
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
_config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
_discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Setup sensors from YAML config sensor entries."""
key = _key(None)
+51 -25
View File
@@ -130,6 +130,7 @@ from .const import (
SERVICE_ACTIVATE_PLAYBOOK,
SERVICE_CALIBRATE_ENERGY,
SERVICE_CALIBRATE_UTILITY_METER,
SERVICE_DEBUG_GROUP,
SERVICE_GET_ACTIVE_PLAYBOOK,
SERVICE_GET_GROUP_ENTITIES,
SERVICE_INCREASE_DAILY_ENERGY,
@@ -410,7 +411,7 @@ def _register_entity_id_change_listener(
"""
@callback
async def _entity_rename_listener(event: Event) -> None:
def _entity_rename_listener(event: Event) -> None:
"""Handle renaming of the entity"""
old_entity_id = event.data["old_entity_id"]
new_entity_id = event.data[CONF_ENTITY_ID]
@@ -429,12 +430,11 @@ def _register_entity_id_change_listener(
"""Only dispatch the listener for update events concerning the source entity"""
# Breaking change in 2024.4.0, check for Event for versions prior to this
if type(event) is Event: # Intentionally avoid `isinstance` because it's slow and we trust `Event` is not subclassed
event = event.data # pragma: no cover
event_data = event.data if isinstance(event, Event) else event
return (
event["action"] == "update" # type: ignore
and "old_entity_id" in event # type: ignore
and event["old_entity_id"] == source_entity_id # type: ignore
event_data["action"] == "update"
and "old_entity_id" in event_data
and event_data["old_entity_id"] == source_entity_id
)
hass.bus.async_listen(
@@ -548,6 +548,13 @@ def register_entity_services() -> None:
supports_response=SupportsResponse.ONLY,
)
platform.async_register_entity_service(
SERVICE_DEBUG_GROUP,
{},
"debug_group",
supports_response=SupportsResponse.ONLY,
)
def convert_config_entry_to_sensor_config(config_entry: ConfigEntry, hass: HomeAssistant) -> ConfigType: # noqa: C901
"""Convert the config entry structure to the sensor config used to create the entities."""
@@ -572,7 +579,9 @@ def convert_config_entry_to_sensor_config(config_entry: ConfigEntry, hass: HomeA
"""Convert on_time dictionary to timedelta."""
on_time = config.get(CONF_ON_TIME)
config[CONF_ON_TIME] = (
timedelta(hours=on_time["hours"], minutes=on_time["minutes"], seconds=on_time["seconds"]) if on_time else timedelta(days=1)
timedelta(hours=on_time["hours"], minutes=on_time["minutes"], seconds=on_time["seconds"])
if on_time
else timedelta(days=1)
)
def process_states_power(states_power: dict | list) -> dict:
@@ -582,7 +591,10 @@ def convert_config_entry_to_sensor_config(config_entry: ConfigEntry, hass: HomeA
"""
if isinstance(states_power, list):
states_power = {item[CONF_STATE]: item[CONF_POWER] for item in states_power}
return {key: Template(value, hass) if isinstance(value, str) and "{{" in value else value for key, value in states_power.items()}
return {
key: Template(value, hass) if isinstance(value, str) and "{{" in value else value
for key, value in states_power.items()
}
def process_daily_fixed_energy() -> None:
"""Process daily fixed energy configuration."""
@@ -868,20 +880,15 @@ async def create_individual_sensors(
) -> EntitiesBucket:
"""Create entities (power, energy, utility meters) which track the appliance."""
source_entity = await create_source_entity(sensor_config[CONF_ENTITY_ID], hass)
source_entity = create_source_entity(sensor_config[CONF_ENTITY_ID], hass)
# For device-based profiles, attach the device entry to the source entity
if source_entity.entity_id == DUMMY_ENTITY_ID and "device" in sensor_config:
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(sensor_config["device"])
if device_entry:
source_entity = source_entity._replace(device_entry=device_entry)
source_entity = _attach_configured_device_entry(hass, sensor_config, source_entity)
if (used_unique_ids := hass.data[DOMAIN].get(DATA_USED_UNIQUE_IDS)) is None:
used_unique_ids = hass.data[DOMAIN][DATA_USED_UNIQUE_IDS] = [] # pragma: no cover
used_unique_ids = hass.data[DOMAIN].get(DATA_USED_UNIQUE_IDS, [])
try:
await check_entity_not_already_configured(
check_entity_not_already_configured(
sensor_config,
source_entity,
hass,
@@ -905,17 +912,17 @@ async def create_individual_sensors(
except PowercalcSetupError:
return EntitiesBucket()
entities_to_add.append(power_sensor)
energy_sensor = await create_energy_sensor_if_needed(hass, sensor_config, power_sensor, source_entity)
energy_sensor = create_energy_sensor_if_needed(hass, sensor_config, power_sensor, source_entity)
if energy_sensor:
entities_to_add.append(energy_sensor)
attach_energy_sensor_to_power_sensor(power_sensor, energy_sensor)
if energy_sensor:
entities_to_add.extend(await create_utility_meters(hass, energy_sensor, sensor_config, config_entry))
entities_to_add.extend(create_utility_meters(hass, energy_sensor, sensor_config, config_entry))
await attach_entities_to_source_device(config_entry, entities_to_add, hass, source_entity)
update_registries(hass, source_entity, entities_to_add, context)
unique_id = sensor_config.get(CONF_UNIQUE_ID) or source_entity.unique_id
if unique_id:
used_unique_ids.append(unique_id)
@@ -925,6 +932,21 @@ async def create_individual_sensors(
return EntitiesBucket(new=entities_to_add, existing=[])
def _attach_configured_device_entry(
hass: HomeAssistant,
sensor_config: dict,
source_entity: SourceEntity,
) -> SourceEntity:
if source_entity.entity_id != DUMMY_ENTITY_ID or "device" not in sensor_config:
return source_entity
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(sensor_config["device"])
if device_entry:
return source_entity._replace(device_entry=device_entry)
return source_entity
async def handle_energy_sensor_creation(
hass: HomeAssistant,
sensor_config: dict,
@@ -933,7 +955,7 @@ async def handle_energy_sensor_creation(
) -> EnergySensor | None:
"""Handle the creation of an energy sensor if needed."""
if CONF_DAILY_FIXED_ENERGY in sensor_config:
energy_sensor = await create_daily_fixed_energy_sensor(hass, sensor_config, source_entity)
energy_sensor = create_daily_fixed_energy_sensor(hass, sensor_config, source_entity)
entities_to_add.append(energy_sensor)
if source_entity:
daily_fixed_power_sensor = await create_daily_fixed_energy_power_sensor(hass, sensor_config, source_entity)
@@ -943,15 +965,19 @@ async def handle_energy_sensor_creation(
return None
async def create_energy_sensor_if_needed(
def create_energy_sensor_if_needed(
hass: HomeAssistant,
sensor_config: dict,
power_sensor: PowerSensor,
source_entity: SourceEntity,
) -> EnergySensor | None:
"""Create an energy sensor if it is needed."""
if sensor_config.get(CONF_CREATE_ENERGY_SENSOR) or sensor_config.get(CONF_FORCE_ENERGY_SENSOR_CREATION) or CONF_ENERGY_SENSOR_ID in sensor_config:
return await create_energy_sensor(hass, sensor_config, power_sensor, source_entity)
if (
sensor_config.get(CONF_CREATE_ENERGY_SENSOR)
or sensor_config.get(CONF_FORCE_ENERGY_SENSOR_CREATION)
or CONF_ENERGY_SENSOR_ID in sensor_config
):
return create_energy_sensor(hass, sensor_config, power_sensor, source_entity)
return None
@@ -976,7 +1002,7 @@ def update_registries(
domain_entities.extend(entities_to_add)
async def check_entity_not_already_configured(
def check_entity_not_already_configured(
sensor_config: dict,
source_entity: SourceEntity,
hass: HomeAssistant,
@@ -3,10 +3,7 @@ from __future__ import annotations
import logging
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
CONF_NAME,
__version__ as HA_VERSION, # noqa
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.entity import Entity, async_generate_entity_id
@@ -75,7 +75,7 @@ DAILY_FIXED_ENERGY_SCHEMA = vol.Schema(
_LOGGER = logging.getLogger(__name__)
async def create_daily_fixed_energy_sensor(
def create_daily_fixed_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
source_entity: SourceEntity | None = None,
@@ -134,7 +134,10 @@ async def create_daily_fixed_energy_power_sensor(
return None
power_value: float = mode_config.get(CONF_VALUE) # type: ignore
if mode_config.get(CONF_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR and not isinstance(power_value, Template):
if mode_config.get(CONF_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR and not isinstance(
power_value,
Template,
):
power_value = power_value * 1000 / 24
power_sensor_config = sensor_config.copy()
@@ -244,7 +247,9 @@ class DailyEnergySensor(RestoreEntity, SensorEntity, EnergySensor):
self.hass,
refresh,
timedelta(seconds=self._update_frequency),
cancel_on_shutdown=True,
)
self.async_on_remove(self._update_timer_removal)
def calculate_delta(self, elapsed_seconds: int = 0) -> Decimal:
if self._last_delta_calculate is None:
@@ -267,7 +272,11 @@ class DailyEnergySensor(RestoreEntity, SensorEntity, EnergySensor):
)
return Decimal(0)
wh_per_day = value * (self._on_time.total_seconds() / 3600) if self._user_unit_of_measurement == UnitOfPower.WATT else value * 1000
wh_per_day = (
value * (self._on_time.total_seconds() / 3600)
if self._user_unit_of_measurement == UnitOfPower.WATT
else value * 1000
)
# Convert Wh to the native measurement unit
energy_per_day = wh_per_day
+21 -13
View File
@@ -60,7 +60,7 @@ ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
_LOGGER = logging.getLogger(__name__)
async def create_energy_sensor(
def create_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
power_sensor: PowerSensor,
@@ -69,20 +69,20 @@ async def create_energy_sensor(
"""Create the energy sensor entity."""
# Check for existing energy sensor
energy_sensor = await _get_existing_energy_sensor(hass, sensor_config)
energy_sensor = _get_existing_energy_sensor(hass, sensor_config)
if energy_sensor:
return energy_sensor
# Check if we should find or create a related energy sensor
energy_sensor = await _get_related_energy_sensor(hass, sensor_config, power_sensor)
energy_sensor = _get_related_energy_sensor(hass, sensor_config, power_sensor)
if energy_sensor:
return energy_sensor
# Create a new virtual energy sensor based on the virtual power sensor
return await _create_virtual_energy_sensor(hass, sensor_config, power_sensor, source_entity)
return _create_virtual_energy_sensor(hass, sensor_config, power_sensor, source_entity)
async def _get_existing_energy_sensor(
def _get_existing_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
) -> EnergySensor | None:
@@ -95,7 +95,8 @@ async def _get_existing_energy_sensor(
entity_entry = ent_reg.async_get(energy_sensor_id)
if entity_entry is None:
raise SensorConfigurationError(
f"No energy sensor with id {energy_sensor_id} found in your HA instance. Double check `energy_sensor_id` setting",
f"No energy sensor with id {energy_sensor_id} found in your HA instance. "
"Double check `energy_sensor_id` setting",
)
return RealEnergySensor(
entity_entry.entity_id,
@@ -104,7 +105,7 @@ async def _get_existing_energy_sensor(
)
async def _get_related_energy_sensor(
def _get_related_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
power_sensor: PowerSensor,
@@ -137,7 +138,7 @@ async def _get_related_energy_sensor(
return None
async def _create_virtual_energy_sensor(
def _create_virtual_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
power_sensor: PowerSensor,
@@ -188,10 +189,15 @@ def get_unit_prefix(
) -> str | None:
unit_prefix = sensor_config.get(CONF_ENERGY_SENSOR_UNIT_PREFIX)
power_unit = UnitOfPower(power_sensor.unit_of_measurement) # type: ignore
try:
power_unit: UnitOfPower | str | None = (
UnitOfPower(power_sensor.unit_of_measurement) if power_sensor.unit_of_measurement else None
)
except ValueError:
power_unit = None
power_state = hass.states.get(power_sensor.entity_id)
if power_unit is None and power_state: # type: ignore
power_unit = power_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # type: ignore # pragma: no cover
if power_unit is None and power_state:
power_unit = power_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # pragma: no cover
# When the power sensor is in kW, we don't want to add an extra k prefix.
# As this would result in an energy sensor having kkWh unit, which is obviously invalid
@@ -269,14 +275,16 @@ class VirtualEnergySensor(IntegrationSensor, EnergySensor):
"integration_method": integration_method,
"unique_id": unique_id,
"device_info": device_info,
"max_sub_interval": timedelta(seconds=sensor_config.get(CONF_ENERGY_UPDATE_INTERVAL, DEFAULT_ENERGY_UPDATE_INTERVAL)),
"max_sub_interval": timedelta(
seconds=sensor_config.get(CONF_ENERGY_UPDATE_INTERVAL, DEFAULT_ENERGY_UPDATE_INTERVAL),
),
}
signature = inspect.signature(IntegrationSensor.__init__)
params = {key: val for key, val in params.items() if key in signature.parameters}
super().__init__(**params) # type: ignore[arg-type]
super().__init__(**params) # type: ignore[arg-type] # ty: ignore[invalid-argument-type]
self._powercalc_source_entity = powercalc_source_entity
self._powercalc_source_domain = powercalc_source_domain
@@ -20,11 +20,11 @@ from custom_components.powercalc.const import (
_LOGGER = logging.getLogger(__name__)
async def remove_power_sensor_from_associated_groups(
def remove_power_sensor_from_associated_groups(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> list[ConfigEntry]:
"""When the user remove a virtual power config entry we need to update all the groups which this sensor belongs to."""
"""When the user removes a virtual power config entry, update all groups this sensor belongs to."""
group_entries = get_groups_having_member(hass, config_entry)
for group_entry in group_entries:
@@ -39,18 +39,18 @@ async def remove_power_sensor_from_associated_groups(
return group_entries
async def add_to_associated_groups(hass: HomeAssistant, config_entry: ConfigEntry) -> ConfigEntry | None: # type: ignore
async def add_to_associated_groups(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""
When the user has set a group on a virtual power config entry,
we need to add this config entry to the group members sensors and update the group.
"""
sensor_type = config_entry.data.get(CONF_SENSOR_TYPE)
if sensor_type not in [SensorType.VIRTUAL_POWER, SensorType.DAILY_ENERGY]:
return None
return
raw_groups = config_entry.data.get(CONF_GROUP)
if not raw_groups:
return None
return
group_ids = raw_groups if isinstance(raw_groups, list) else [raw_groups]
for group_entry_id in group_ids:
@@ -130,7 +130,9 @@ async def add_to_associated_group(
def get_entries_having_subgroup(hass: HomeAssistant, subgroup_entry: ConfigEntry) -> list[ConfigEntry]:
"""Get all virtual power entries which have the subgroup in their subgroups list."""
return [entry for entry in get_group_entries(hass) if subgroup_entry.entry_id in (entry.data.get(CONF_SUB_GROUPS) or [])]
return [
entry for entry in get_group_entries(hass) if subgroup_entry.entry_id in (entry.data.get(CONF_SUB_GROUPS) or [])
]
def get_groups_having_member(hass: HomeAssistant, member_entry: ConfigEntry) -> list[ConfigEntry]:
@@ -138,7 +140,8 @@ def get_groups_having_member(hass: HomeAssistant, member_entry: ConfigEntry) ->
return [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data.get(CONF_SENSOR_TYPE) == SensorType.GROUP and member_entry.entry_id in (entry.data.get(CONF_GROUP_MEMBER_SENSORS) or [])
if entry.data.get(CONF_SENSOR_TYPE) == SensorType.GROUP
and member_entry.entry_id in (entry.data.get(CONF_GROUP_MEMBER_SENSORS) or [])
]
@@ -154,4 +157,6 @@ def get_group_entries(hass: HomeAssistant, group_type: GroupType | None = None)
@callback
def get_entries_excluding_global_config(hass: HomeAssistant) -> list[ConfigEntry]:
return [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != ENTRY_GLOBAL_CONFIG_UNIQUE_ID]
return [
entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != ENTRY_GLOBAL_CONFIG_UNIQUE_ID
]
@@ -59,6 +59,8 @@ from custom_components.powercalc.analytics.analytics import collect_analytics
from custom_components.powercalc.const import (
ATTR_ENTITIES,
ATTR_IS_GROUP,
ATTR_MEMBERS,
ATTR_STATE,
CONF_ALL,
CONF_AREA,
CONF_CREATE_ENERGY_SENSOR,
@@ -99,7 +101,14 @@ from custom_components.powercalc.const import (
UnitPrefix,
)
from custom_components.powercalc.device_binding import get_device_info
from custom_components.powercalc.group_include.filter import AreaFilter, CompositeFilter, DeviceFilter, EntityFilter, FilterOperator, FloorFilter
from custom_components.powercalc.group_include.filter import (
AreaFilter,
CompositeFilter,
DeviceFilter,
EntityFilter,
FilterOperator,
FloorFilter,
)
from custom_components.powercalc.group_include.include import find_entities
from custom_components.powercalc.helpers import async_cache
from custom_components.powercalc.sensors.abstract import (
@@ -133,7 +142,7 @@ UNIT_CONVERTERS: dict[str | None, type[BaseUnitConverter]] = {
}
async def create_group_sensors_yaml(
def create_group_sensors_yaml(
hass: HomeAssistant,
sensor_config: dict[str, Any],
entities: list[Entity],
@@ -152,7 +161,7 @@ async def create_group_sensors_yaml(
)
group_name = str(sensor_config.get(CONF_CREATE_GROUP))
return await create_group_sensors_custom(hass, group_name, sensor_config, power_sensor_ids, energy_sensor_ids)
return create_group_sensors_custom(hass, group_name, sensor_config, power_sensor_ids, energy_sensor_ids)
async def create_group_sensors_gui(
@@ -171,10 +180,10 @@ async def create_group_sensors_gui(
energy_sensor_ids = await resolve_entity_ids_recursively(hass, entry, SensorDeviceClass.ENERGY)
return await create_group_sensors_custom(hass, group_name, sensor_config, power_sensor_ids, energy_sensor_ids)
return create_group_sensors_custom(hass, group_name, sensor_config, power_sensor_ids, energy_sensor_ids)
async def create_group_sensors_custom(
def create_group_sensors_custom(
hass: HomeAssistant,
group_name: str,
sensor_config: dict[str, Any],
@@ -216,7 +225,7 @@ async def create_group_sensors_custom(
sensor_config[CONF_UTILITY_METER_NET_CONSUMPTION] = True
group_sensors.extend(
await create_utility_meters(
create_utility_meters(
hass,
energy_sensor,
sensor_config,
@@ -273,64 +282,83 @@ async def resolve_entity_ids_recursively(
if resolved_ids is None:
resolved_ids = set()
def add_member_entry_ids() -> None:
"""Add power/energy sensors from the group member entries."""
member_entry_ids = entry.data.get(CONF_GROUP_MEMBER_SENSORS) or []
for member_entry_id in member_entry_ids:
member_entry = hass.config_entries.async_get_entry(member_entry_id)
if member_entry is None:
continue
key = resolve_key_based_on_device_class(member_entry)
if key and key in member_entry.data:
resolved_ids.add(str(member_entry.data.get(key)))
def resolve_key_based_on_device_class(member_entry: ConfigEntry) -> str | None:
"""Resolve the correct key for power/energy sensor based on device class."""
if member_entry.data.get(CONF_SENSOR_TYPE) == SensorType.REAL_POWER:
return CONF_ENTITY_ID if device_class == SensorDeviceClass.POWER else ENTRY_DATA_ENERGY_ENTITY
return ENTRY_DATA_POWER_ENTITY if device_class == SensorDeviceClass.POWER else ENTRY_DATA_ENERGY_ENTITY
def add_specified_sensors() -> None:
"""Add additional power/energy sensors specified by the user."""
conf_key = CONF_GROUP_POWER_ENTITIES if device_class == SensorDeviceClass.POWER else CONF_GROUP_ENERGY_ENTITIES
resolved_ids.update(entry.data.get(conf_key) or [])
async def add_include_based_sensors() -> None:
"""Add entities from the defined areas, devices and floors."""
if all(k not in entry.data for k in (CONF_AREA, CONF_FLOOR, CONF_GROUP_MEMBER_DEVICES)):
return
result = await find_entities(
hass,
await build_entity_include_filter(hass, entry),
bool(entry.data.get(CONF_INCLUDE_NON_POWERCALC_SENSORS)),
)
resolved_ids.update(filter_entity_list_by_class(result.resolved, device_class))
async def add_subgroup_entities() -> None:
"""Recursively add entities from subgroups."""
subgroups = entry.data.get(CONF_SUB_GROUPS)
if not subgroups:
return
for subgroup_entry_id in subgroups:
subgroup_entry = hass.config_entries.async_get_entry(subgroup_entry_id)
if subgroup_entry is None:
_LOGGER.error("Subgroup config entry not found: %s", subgroup_entry_id)
continue
await resolve_entity_ids_recursively(hass, subgroup_entry, device_class, resolved_ids)
# Process the main logic
add_member_entry_ids()
add_specified_sensors()
await add_include_based_sensors()
await add_subgroup_entities()
_add_member_entry_ids(hass, entry, device_class, resolved_ids)
_add_specified_sensors(entry, device_class, resolved_ids)
await _add_include_based_sensors(hass, entry, device_class, resolved_ids)
await _add_subgroup_entities(hass, entry, device_class, resolved_ids)
return resolved_ids
def _add_member_entry_ids(
hass: HomeAssistant,
entry: ConfigEntry,
device_class: SensorDeviceClass,
resolved_ids: set[str],
) -> None:
"""Add power/energy sensors from the group member entries."""
member_entry_ids = entry.data.get(CONF_GROUP_MEMBER_SENSORS) or []
for member_entry_id in member_entry_ids:
member_entry = hass.config_entries.async_get_entry(member_entry_id)
if member_entry is None:
continue
key = _resolve_key_based_on_device_class(member_entry, device_class)
if key and key in member_entry.data:
resolved_ids.add(str(member_entry.data.get(key)))
def _resolve_key_based_on_device_class(member_entry: ConfigEntry, device_class: SensorDeviceClass) -> str | None:
"""Resolve the correct key for power/energy sensor based on device class."""
if member_entry.data.get(CONF_SENSOR_TYPE) == SensorType.REAL_POWER:
return CONF_ENTITY_ID if device_class == SensorDeviceClass.POWER else ENTRY_DATA_ENERGY_ENTITY
return ENTRY_DATA_POWER_ENTITY if device_class == SensorDeviceClass.POWER else ENTRY_DATA_ENERGY_ENTITY
def _add_specified_sensors(entry: ConfigEntry, device_class: SensorDeviceClass, resolved_ids: set[str]) -> None:
"""Add additional power/energy sensors specified by the user."""
conf_key = CONF_GROUP_POWER_ENTITIES if device_class == SensorDeviceClass.POWER else CONF_GROUP_ENERGY_ENTITIES
resolved_ids.update(entry.data.get(conf_key) or [])
async def _add_include_based_sensors(
hass: HomeAssistant,
entry: ConfigEntry,
device_class: SensorDeviceClass,
resolved_ids: set[str],
) -> None:
"""Add entities from the defined areas, devices and floors."""
if all(k not in entry.data for k in (CONF_AREA, CONF_FLOOR, CONF_GROUP_MEMBER_DEVICES)):
return
result = await find_entities(
hass,
await build_entity_include_filter(hass, entry),
bool(entry.data.get(CONF_INCLUDE_NON_POWERCALC_SENSORS)),
)
resolved_ids.update(filter_entity_list_by_class(result.resolved, device_class))
async def _add_subgroup_entities(
hass: HomeAssistant,
entry: ConfigEntry,
device_class: SensorDeviceClass,
resolved_ids: set[str],
) -> None:
"""Recursively add entities from subgroups."""
subgroups = entry.data.get(CONF_SUB_GROUPS)
if not subgroups:
return
for subgroup_entry_id in subgroups:
subgroup_entry = hass.config_entries.async_get_entry(subgroup_entry_id)
if subgroup_entry is None:
_LOGGER.error("Subgroup config entry not found: %s", subgroup_entry_id)
continue
await resolve_entity_ids_recursively(hass, subgroup_entry, device_class, resolved_ids)
@callback
def create_grouped_power_sensor(
hass: HomeAssistant,
@@ -448,11 +476,17 @@ class GroupedSensor(BaseEntity, SensorEntity):
self._entities = entities
self._sensor_config = sensor_config
if self._is_energy_sensor:
self._rounding_digits = int(sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION))
self._update_interval: int = int(sensor_config.get(CONF_GROUP_ENERGY_UPDATE_INTERVAL, DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL))
self._rounding_digits = int(
sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION),
)
self._update_interval: int = int(
sensor_config.get(CONF_GROUP_ENERGY_UPDATE_INTERVAL, DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL),
)
else:
self._rounding_digits = int(sensor_config.get(CONF_POWER_SENSOR_PRECISION, DEFAULT_POWER_SENSOR_PRECISION))
self._update_interval = int(sensor_config.get(CONF_GROUP_POWER_UPDATE_INTERVAL, DEFAULT_GROUP_POWER_UPDATE_INTERVAL))
self._update_interval = int(
sensor_config.get(CONF_GROUP_POWER_UPDATE_INTERVAL, DEFAULT_GROUP_POWER_UPDATE_INTERVAL),
)
self._attr_suggested_display_precision = self._rounding_digits
if unique_id:
self._attr_unique_id = unique_id
@@ -523,7 +557,11 @@ class GroupedSensor(BaseEntity, SensorEntity):
domain = self._sensor_config.get(CONF_DOMAIN)
if domain == CONF_ALL:
entity_registry = er.async_get(self.hass)
entities = {entity.entity_id for entity in entity_registry.entities.values() if entity.device_class == self.device_class}
entities = {
entity.entity_id
for entity in entity_registry.entities.values()
if entity.device_class == self.device_class
}
else:
entities = self.hass.data[DOMAIN].get(DATA_DOMAIN_ENTITIES).get(domain, [])
entities = filter_entity_list_by_class(
@@ -533,7 +571,7 @@ class GroupedSensor(BaseEntity, SensorEntity):
excluded_entities = self._sensor_config.get(CONF_EXCLUDE_ENTITIES) or []
self._entities = set({entity for entity in entities if entity not in excluded_entities})
async def on_start(self, _: Any) -> None: # noqa
async def on_start(self, _: HomeAssistant) -> None:
"""Initialize group sensor when HA is starting."""
await self.init_domain_group()
@@ -557,7 +595,9 @@ class GroupedSensor(BaseEntity, SensorEntity):
"""Initial update for the group sensor state."""
all_states = [self.hass.states.get(entity_id) for entity_id in self._entities]
states: list[State] = list(filter(None, all_states))
available_states = [state for state in states if state and state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]]
available_states = [
state for state in states if state and state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]
]
if not available_states and not self._ignore_unavailable_state:
new_state: Decimal | str = STATE_UNAVAILABLE
else:
@@ -652,6 +692,37 @@ class GroupedSensor(BaseEntity, SensorEntity):
def get_group_entities(self) -> dict[str, set[str]]:
return {ATTR_ENTITIES: self._entities}
def debug_group(self) -> dict[str, Any]:
members: dict[str, dict[str, str | None]] = {}
for entity_id in sorted(self._entities):
members[entity_id] = self._get_member_debug_info(entity_id)
return {
ATTR_STATE: str(self.state),
ATTR_UNIT_OF_MEASUREMENT: self.native_unit_of_measurement,
ATTR_MEMBERS: members,
}
def _get_member_debug_info(self, entity_id: str) -> dict[str, str | None]:
state = self.hass.states.get(entity_id)
if state is None:
return {
ATTR_STATE: None,
ATTR_UNIT_OF_MEASUREMENT: None,
}
if state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
return {
ATTR_STATE: str(state.state),
ATTR_UNIT_OF_MEASUREMENT: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT),
}
converted_value = round(self._get_state_value_in_native_unit(state), self._rounding_digits)
return {
ATTR_STATE: str(converted_value),
ATTR_UNIT_OF_MEASUREMENT: self.native_unit_of_measurement,
}
@abstractmethod
def calculate_initial_state(
self,
@@ -681,7 +752,9 @@ class GroupedPowerSensor(GroupedSensor, PowerSensor):
member_available_states: list[State],
member_states: list[State],
) -> Decimal | str:
self._member_states = {state.entity_id: self._get_state_value_in_native_unit(state) for state in member_available_states}
self._member_states = {
state.entity_id: self._get_state_value_in_native_unit(state) for state in member_available_states
}
return self.get_summed_state()
def calculate_new_state(self, state: State) -> Decimal | str:
@@ -776,8 +849,9 @@ class GroupedEnergySensor(GroupedSensor, RestoreSensor, EnergySensor):
member_available_states: list[State],
member_states: list[State],
) -> Decimal:
"""Calculate the new group energy sensor state
For each member sensor we calculate the delta by looking at the previous known state and compare it to the current.
"""Calculate the new group energy sensor state.
For each member, calculate the delta between the previous known state and the current.
"""
group_sum = Decimal(self._native_value_exact) if self._native_value_exact else Decimal(0)
_LOGGER.debug("%s: Recalculate, current value: %s", self.entity_id, group_sum)
@@ -830,10 +904,7 @@ class GroupedEnergySensor(GroupedSensor, RestoreSensor, EnergySensor):
)
start_at_zero = self._sensor_config.get(CONF_GROUP_ENERGY_START_AT_ZERO, True)
if prev_state is None and start_at_zero: # noqa: SIM108
delta = Decimal(0)
else:
delta = cur_value - prev_value
delta = Decimal(0) if prev_state is None and start_at_zero else cur_value - prev_value
if _LOGGER.isEnabledFor(logging.DEBUG): # pragma: no cover
_LOGGER.debug(
@@ -890,7 +961,9 @@ class PreviousStateStore:
_LOGGER.debug("Load previous energy sensor states from store")
stored_states = await instance.store.async_load() or {}
for group, entities in stored_states.items():
instance.states[group] = {entity_id: State.from_dict(json_state) for (entity_id, json_state) in entities.items()}
instance.states[group] = {
entity_id: State.from_dict(json_state) for (entity_id, json_state) in entities.items()
}
except HomeAssistantError as exc: # pragma: no cover
_LOGGER.error("Error loading previous energy sensor states", exc_info=exc)
@@ -943,7 +1016,7 @@ class PreviousStateStore:
def async_setup_dump(self) -> None:
"""Set up the listeners for persistence."""
async def _async_dump_states(*_: Any) -> None: # noqa: ANN401
async def _async_dump_states(*_: object) -> None:
await self.persist_states()
# Dump states periodically
@@ -951,9 +1024,10 @@ class PreviousStateStore:
self.hass,
_async_dump_states,
STATE_DUMP_INTERVAL,
cancel_on_shutdown=True,
)
async def _async_dump_states_at_stop(*_: Any) -> None: # noqa: ANN401
async def _async_dump_states_at_stop(*_: object) -> None:
cancel_interval()
await self.persist_states()
@@ -7,7 +7,7 @@ from custom_components.powercalc.const import CONF_GROUP_TYPE, GroupType
from custom_components.powercalc.sensors.group.custom import create_group_sensors_custom
async def create_domain_group_sensor(
def create_domain_group_sensor(
hass: HomeAssistant,
config: ConfigType,
) -> list[Entity]:
@@ -16,7 +16,7 @@ async def create_domain_group_sensor(
if CONF_UNIQUE_ID not in config:
config[CONF_UNIQUE_ID] = generate_unique_id(config)
config[CONF_GROUP_TYPE] = GroupType.DOMAIN
return await create_group_sensors_custom(
return create_group_sensors_custom(
hass,
name,
config,
@@ -24,12 +24,12 @@ async def create_group_sensors(
collect_analytics(hass, config_entry).inc(DATA_GROUP_TYPES, group_type)
if group_type == GroupType.DOMAIN:
return await domain_group.create_domain_group_sensor(
return domain_group.create_domain_group_sensor(
hass,
sensor_config,
)
if group_type == GroupType.STANDBY:
return await standby_group.create_general_standby_sensors(hass, sensor_config)
return standby_group.create_general_standby_sensors(hass, sensor_config)
if group_type == GroupType.CUSTOM:
if config_entry:
@@ -38,14 +38,14 @@ async def create_group_sensors(
entry=config_entry,
sensor_config=sensor_config,
)
return await custom_group.create_group_sensors_yaml(
return custom_group.create_group_sensors_yaml(
hass=hass,
sensor_config=sensor_config,
entities=entities or [],
)
if group_type == GroupType.SUBTRACT:
return await subtract_group.create_subtract_group_sensors(
return subtract_group.create_subtract_group_sensors(
hass=hass,
config=sensor_config,
)
@@ -30,7 +30,7 @@ from custom_components.powercalc.sensors.power import PowerSensor
_LOGGER = logging.getLogger(__name__)
async def create_general_standby_sensors(
def create_general_standby_sensors(
hass: HomeAssistant,
config: ConfigType,
) -> list[Entity]:
@@ -44,8 +44,8 @@ async def create_general_standby_sensors(
power_sensor.entity_id = "sensor.all_standby_power"
sensor_config = config.copy()
sensor_config[CONF_NAME] = "All standby"
source_entity = await create_source_entity(DUMMY_ENTITY_ID, hass)
energy_sensor = await create_energy_sensor(
source_entity = create_source_entity(DUMMY_ENTITY_ID, hass)
energy_sensor = create_energy_sensor(
hass,
sensor_config,
power_sensor,
@@ -80,8 +80,8 @@ class StandbyPowerSensor(SensorEntity, PowerSensor):
"""Calculate sum of all power sensors in standby, and update the state of the sensor."""
if self.standby_sensors:
self._attr_native_value = Decimal(
round( # type: ignore
sum(self.standby_sensors.values()),
round(
sum(self.standby_sensors.values(), Decimal(0)),
self._rounding_digits,
),
)

Some files were not shown because too many files have changed in this diff Show More