Initil after Upgrade
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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(".")
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"get_group_entities": {
|
||||
"service": "mdi:format-list-group"
|
||||
},
|
||||
"debug_group": {
|
||||
"service": "mdi:bug-outline"
|
||||
},
|
||||
"increase_daily_energy": {
|
||||
"service": "mdi:numeric"
|
||||
},
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"requirements": [
|
||||
"numpy>=1.21.1"
|
||||
],
|
||||
"version": "v1.20.14"
|
||||
"version": "v1.21.0"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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")
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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]:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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
Reference in New Issue
Block a user