Initil after Upgrade

This commit is contained in:
2026-06-15 10:53:52 -04:00
parent 2fe9bf0dd6
commit 887feaa50a
143 changed files with 2288 additions and 881 deletions
@@ -1,4 +1,4 @@
from collections.abc import Callable, Coroutine
from collections.abc import Awaitable, Callable, Coroutine
import copy
from dataclasses import dataclass
from enum import StrEnum
@@ -59,6 +59,9 @@ class FlowType(StrEnum):
GLOBAL_CONFIGURATION = "global_configuration"
type MaybeAwaitable[R] = R | Awaitable[R]
@dataclass(slots=True)
class PowercalcFormStep:
schema: vol.Schema | Callable[[], Coroutine[Any, Any, vol.Schema | None]]
@@ -66,12 +69,12 @@ class PowercalcFormStep:
validate_user_input: (
Callable[
[dict[str, Any]],
Coroutine[Any, Any, dict[str, Any]],
MaybeAwaitable[dict[str, Any]],
]
| None
) = None
next_step: Step | Callable[[dict[str, Any]], Coroutine[Any, Any, Step | None]] | None = None
next_step: Step | Callable[[dict[str, Any]], MaybeAwaitable[Step | None]] | None = None
continue_utility_meter_options_step: bool = False
continue_advanced_step: bool = False
form_kwarg: dict[str, Any] | None = None
@@ -88,9 +91,85 @@ def fill_schema_defaults(
new_key = key
if key in options and isinstance(key, vol.Marker):
if isinstance(key, vol.Optional) and callable(key.default) and key.default():
new_key = vol.Optional(key.schema, default=options.get(key)) # type: ignore
new_key = vol.Optional(key.schema, default=options.get(key)) # type: ignore[call-overload]
elif isinstance(key, vol.Required):
new_key = vol.Required(key.schema, default=options.get(key)) # type: ignore[call-overload]
new_key.description = {"suggested_value": options.get(key)} # type: ignore[call-overload]
elif "suggested_value" not in (new_key.description or {}):
new_key = copy.copy(key)
new_key.description = {"suggested_value": options.get(key)} # type: ignore
new_key.description = {"suggested_value": options.get(key)} # type: ignore[call-overload]
schema[new_key] = val
return vol.Schema(schema)
def unwrap_choose_selector(
user_input: dict[str, Any],
wrapper_key: str,
value_key: str | Callable[[object], str] | None = None,
) -> dict[str, Any]:
"""
Unwrap a ChooseSelector value in user_input back into flat keys.
A ChooseSelector value looks like {"active_choice": "<key>", "<key>": <value>}.
The wrapper key is dropped, and the active choice's value is merged back into user_input.
Home Assistant schema validation returns the selected value directly; use ``value_key``
to map that validated value back to a config key.
"""
if wrapper_key not in user_input:
return user_input
raw = user_input.pop(wrapper_key)
if not isinstance(raw, dict):
if isinstance(value_key, str):
user_input[value_key] = raw
elif value_key is not None:
user_input[value_key(raw)] = raw
return user_input
if "active_choice" not in raw:
user_input.update(raw)
return user_input
active = raw["active_choice"]
value = raw.get(active)
if value is None:
return user_input
if isinstance(value, dict):
user_input.update(value)
else:
user_input[active] = value
return user_input
def wrap_choose_selector(
form_data: dict[str, Any],
wrapper_key: str,
choices: dict[str, list[str] | str],
*,
raw_value: bool = False,
) -> dict[str, Any]:
"""
Build the ChooseSelector value for ``wrapper_key`` from existing flat ``form_data``.
``choices`` maps the choice id to either a single key (the value of that key becomes
the choice value) or a list of keys (a dict of those keys becomes the choice value).
The first choice that has a matching key in form_data is used.
"""
for choice_id, mapping in choices.items():
keys = [mapping] if isinstance(mapping, str) else mapping
present = {key: form_data[key] for key in keys if key in form_data}
if not present:
continue
if isinstance(mapping, str):
choice_value: Any = present[mapping]
else:
choice_value = present
if raw_value:
return {**form_data, wrapper_key: choice_value}
return {**form_data, wrapper_key: {"active_choice": choice_id, choice_id: choice_value}}
return form_data
@@ -7,7 +7,11 @@ from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.power_profile.power_profile import PowerProfile
def build_dynamic_field_schema(hass: HomeAssistant, profile: PowerProfile, source_entity: SourceEntity | None) -> vol.Schema:
def build_dynamic_field_schema(
hass: HomeAssistant,
profile: PowerProfile,
source_entity: SourceEntity | None,
) -> vol.Schema:
schema = {}
for field in profile.custom_fields:
field_description = field.description
@@ -22,7 +26,8 @@ def build_dynamic_field_schema(hass: HomeAssistant, profile: PowerProfile, sourc
if "entity" in field.selector and source_entity and source_entity.device_entry:
entity_reg = er.async_get(hass)
field.selector["entity"]["include_entities"] = [
entity.entity_id for entity in entity_reg.entities.get_entries_for_device_id(source_entity.device_entry.id)
entity.entity_id
for entity in entity_reg.entities.get_entries_for_device_id(source_entity.device_entry.id)
]
schema[key] = selector(field.selector)
@@ -2,14 +2,22 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfPower, UnitOfTime
from homeassistant.data_entry_flow import FlowResult
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import (
CONF_NAME,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
UnitOfEnergy,
UnitOfPower,
UnitOfTime,
)
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
import voluptuous as vol
from custom_components.powercalc import CONF_CREATE_UTILITY_METERS
from custom_components.powercalc.const import (
CONF_DAILY_ENERGY_VALUE,
CONF_DAILY_FIXED_ENERGY,
CONF_GROUP,
CONF_ON_TIME,
@@ -18,17 +26,35 @@ from custom_components.powercalc.const import (
CONF_VALUE_TEMPLATE,
SensorType,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, fill_schema_defaults
from custom_components.powercalc.flow_helper.common import (
PowercalcFormStep,
Step,
fill_schema_defaults,
unwrap_choose_selector,
wrap_choose_selector,
)
from custom_components.powercalc.flow_helper.schema import SCHEMA_UTILITY_METER_TOGGLE
from custom_components.powercalc.sensors.daily_energy import DEFAULT_DAILY_UPDATE_FREQUENCY
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcConfigFlow, PowercalcOptionsFlow
DAILY_ENERGY_VALUE_CHOICES: dict[str, list[str] | str] = {
CONF_VALUE_TEMPLATE: CONF_VALUE_TEMPLATE,
CONF_VALUE: CONF_VALUE,
}
SCHEMA_DAILY_ENERGY_OPTIONS = vol.Schema(
{
vol.Optional(CONF_VALUE): vol.Coerce(float),
vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(),
vol.Optional(CONF_DAILY_ENERGY_VALUE): selector.ChooseSelector(
selector.ChooseSelectorConfig(
choices={
CONF_VALUE: {"selector": {"number": {"mode": "box", "step": "any"}}},
CONF_VALUE_TEMPLATE: {"selector": {"template": {}}},
},
translation_key=CONF_DAILY_ENERGY_VALUE,
),
),
vol.Optional(
CONF_UNIT_OF_MEASUREMENT,
default=UnitOfEnergy.KILO_WATT_HOUR,
@@ -58,13 +84,26 @@ SCHEMA_DAILY_ENERGY = vol.Schema(
).extend(SCHEMA_DAILY_ENERGY_OPTIONS.schema)
def daily_energy_choice_key_from_validated_value(value: object) -> str:
"""Infer the daily energy config key from a validated ChooseSelector value."""
return CONF_VALUE_TEMPLATE if isinstance(value, str) else CONF_VALUE
def build_daily_energy_config(user_input: dict[str, Any], schema: vol.Schema) -> dict[str, Any]:
"""Build the config under daily_energy: key."""
user_input = unwrap_choose_selector(
dict(user_input),
CONF_DAILY_ENERGY_VALUE,
daily_energy_choice_key_from_validated_value,
)
config: dict[str, Any] = {
CONF_DAILY_FIXED_ENERGY: {},
}
schema_keys = {key.schema if isinstance(key, vol.Marker) else key for key in schema.schema}
schema_keys.discard(CONF_DAILY_ENERGY_VALUE)
schema_keys |= {CONF_VALUE, CONF_VALUE_TEMPLATE}
for key, val in user_input.items():
if key in schema.schema and val is not None:
if key in schema_keys and val is not None:
if key in {CONF_CREATE_UTILITY_METERS, CONF_GROUP, CONF_NAME, CONF_UNIQUE_ID}:
config[str(key)] = val
continue
@@ -80,14 +119,19 @@ class DailyEnergyConfigFlow:
async def async_step_daily_energy(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the flow for daily energy sensor."""
self.flow.selected_sensor_type = SensorType.DAILY_ENERGY
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
if CONF_VALUE not in user_input and CONF_VALUE_TEMPLATE not in user_input:
def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
unwrapped = unwrap_choose_selector(
dict(user_input),
CONF_DAILY_ENERGY_VALUE,
daily_energy_choice_key_from_validated_value,
)
if CONF_VALUE not in unwrapped and CONF_VALUE_TEMPLATE not in unwrapped:
raise SchemaFlowError("daily_energy_mandatory")
return build_daily_energy_config(user_input, SCHEMA_DAILY_ENERGY)
return build_daily_energy_config(unwrapped, SCHEMA_DAILY_ENERGY)
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -104,10 +148,21 @@ class DailyEnergyOptionsFlow:
def __init__(self, flow: PowercalcOptionsFlow) -> None:
self.flow: PowercalcOptionsFlow = flow
async def async_step_daily_energy(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_daily_energy(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the daily energy options flow."""
form_data = wrap_choose_selector(
dict(self.flow.sensor_config[CONF_DAILY_FIXED_ENERGY]),
CONF_DAILY_ENERGY_VALUE,
DAILY_ENERGY_VALUE_CHOICES,
)
schema = fill_schema_defaults(
SCHEMA_DAILY_ENERGY_OPTIONS,
self.flow.sensor_config[CONF_DAILY_FIXED_ENERGY],
form_data,
)
if user_input is not None:
user_input = unwrap_choose_selector(
dict(user_input),
CONF_DAILY_ENERGY_VALUE,
daily_energy_choice_key_from_validated_value,
)
return await self.flow.async_handle_options_step(user_input, schema, Step.DAILY_ENERGY)
@@ -3,8 +3,8 @@ from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_ENABLED, CONF_SENSORS, UnitOfTime
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
@@ -44,7 +44,11 @@ from custom_components.powercalc.const import (
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step
from custom_components.powercalc.flow_helper.schema import SCHEMA_ENERGY_OPTIONS, SCHEMA_UTILITY_METER_OPTIONS, SCHEMA_UTILITY_METER_TOGGLE
from custom_components.powercalc.flow_helper.schema import (
SCHEMA_ENERGY_OPTIONS,
SCHEMA_UTILITY_METER_OPTIONS,
SCHEMA_UTILITY_METER_TOGGLE,
)
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
@@ -95,10 +99,16 @@ SCHEMA_GLOBAL_CONFIGURATION_THROTTLING = vol.Schema(
vol.Optional(CONF_ENERGY_UPDATE_INTERVAL, default=DEFAULT_ENERGY_UPDATE_INTERVAL): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
vol.Optional(CONF_GROUP_POWER_UPDATE_INTERVAL, default=DEFAULT_GROUP_POWER_UPDATE_INTERVAL): selector.NumberSelector(
vol.Optional(
CONF_GROUP_POWER_UPDATE_INTERVAL,
default=DEFAULT_GROUP_POWER_UPDATE_INTERVAL,
): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
vol.Optional(CONF_GROUP_ENERGY_UPDATE_INTERVAL, default=DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL): selector.NumberSelector(
vol.Optional(
CONF_GROUP_ENERGY_UPDATE_INTERVAL,
default=DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL,
): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
},
@@ -141,7 +151,10 @@ class GlobalConfigurationFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
async def async_step_global_configuration_discovery(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration_discovery(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the discovery configuration step."""
if user_input is not None:
@@ -162,7 +175,10 @@ class GlobalConfigurationFlow:
user_input,
)
async def async_step_global_configuration_throttling(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration_throttling(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the throttling related options."""
if user_input is not None:
@@ -184,7 +200,10 @@ class GlobalConfigurationFlow:
user_input,
)
async def async_step_global_configuration_energy(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration_energy(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the global configuration step."""
if user_input is not None:
@@ -199,11 +218,18 @@ class GlobalConfigurationFlow:
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_ENERGY,
schema=SCHEMA_GLOBAL_CONFIGURATION_ENERGY_SENSOR,
form_kwarg={"description_placeholders": {"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/"}},
form_kwarg={
"description_placeholders": {
"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/",
},
},
),
)
async def async_step_global_configuration_utility_meter(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration_utility_meter(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the global configuration step."""
if user_input is not None:
@@ -212,7 +238,7 @@ class GlobalConfigurationFlow:
return self.flow.persist_config_entry()
if not bool(self.flow.global_config.get(CONF_CREATE_UTILITY_METERS)) or user_input is not None:
return self.flow.async_create_entry( # type: ignore
return self.flow.async_create_entry(
title="Global Configuration",
data=self.flow.global_config,
)
@@ -221,7 +247,11 @@ class GlobalConfigurationFlow:
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_UTILITY_METER,
schema=SCHEMA_UTILITY_METER_OPTIONS,
form_kwarg={"description_placeholders": {"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/"}},
form_kwarg={
"description_placeholders": {
"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/",
},
},
),
)
@@ -231,7 +261,7 @@ class GlobalConfigurationConfigFlow(GlobalConfigurationFlow):
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the global configuration step."""
get_global_powercalc_config(self.flow)
await self.flow.async_set_unique_id(ENTRY_GLOBAL_CONFIG_UNIQUE_ID)
@@ -272,7 +302,7 @@ class GlobalConfigurationOptionsFlow(GlobalConfigurationFlow):
menu[Step.GLOBAL_CONFIGURATION_UTILITY_METER] = "Utility meter options"
return menu
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the global configuration step."""
if user_input is not None:
@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import Callable, Coroutine
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import SensorDeviceClass
@@ -15,7 +15,6 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
from homeassistant.helpers.selector import TextSelector
@@ -318,7 +317,7 @@ class GroupFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
async def async_step_assign_groups(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_assign_groups(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for assigning groups."""
group_entries = get_group_entries(self.flow.hass, GroupType.CUSTOM)
if not group_entries:
@@ -331,7 +330,7 @@ class GroupFlow:
},
)
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
groups = user_input.get(CONF_GROUP) or []
new_group = user_input.get(CONF_NEW_GROUP)
if new_group:
@@ -360,7 +359,7 @@ class GroupConfigFlow(GroupFlow):
- name: str | None
- selected_sensor_type: str | None
- async_set_unique_id(), _abort_if_unique_id_configured()
- handle_form_step(PowercalcFormStep, user_input) -> FlowResult
- handle_form_step(PowercalcFormStep, user_input) -> ConfigFlowResult
- async_show_menu(...), fill_schema_defaults(...),
- create_group_selector(...), create_schema_group_custom(...)
@@ -371,7 +370,7 @@ class GroupConfigFlow(GroupFlow):
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
async def async_step_menu_group(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
async def async_step_menu_group(self, _: dict[str, Any] | None = None) -> ConfigFlowResult:
menu = [Step.GROUP_CUSTOM, Step.GROUP_DOMAIN, Step.GROUP_SUBTRACT, Step.GROUP_TRACKED_UNTRACKED]
# Hide tracked/untracked if already present
entry = self.flow.hass.config_entries.async_entry_for_domain_unique_id(
@@ -387,9 +386,9 @@ class GroupConfigFlow(GroupFlow):
group_type: GroupType,
user_input: dict[str, Any] | None = None,
schema: vol.Schema | None = None,
next_step: Callable[[dict[str, Any]], Coroutine[Any, Any, Step | None]] | None = None,
) -> FlowResult:
async def _validate(ui: dict[str, Any]) -> dict[str, Any]:
next_step: Callable[[dict[str, Any]], Step | None] | None = None,
) -> ConfigFlowResult:
def _validate(ui: dict[str, Any]) -> dict[str, Any]:
if group_type == GroupType.CUSTOM:
validate_group_input(ui)
@@ -412,24 +411,28 @@ class GroupConfigFlow(GroupFlow):
user_input,
)
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
schema = SCHEMA_GROUP.extend(create_schema_group_custom(self.flow.hass).schema)
return await self.handle_group_step(GroupType.CUSTOM, user_input, schema)
async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
return await self.handle_group_step(GroupType.DOMAIN, user_input)
async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
return await self.handle_group_step(GroupType.SUBTRACT, user_input)
async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
await self.flow.async_set_unique_id(UNIQUE_ID_TRACKED_UNTRACKED)
self.flow.abort_if_unique_id_configured()
if user_input is not None:
user_input[CONF_NAME] = "Tracked / Untracked"
async def _next(ui: dict[str, Any]) -> Step | None:
return Step.GROUP_TRACKED_UNTRACKED_AUTO if bool(ui.get("group_tracked_auto", True)) else Step.GROUP_TRACKED_UNTRACKED_MANUAL
def _next(ui: dict[str, Any]) -> Step | None:
return (
Step.GROUP_TRACKED_UNTRACKED_AUTO
if bool(ui.get("group_tracked_auto", True))
else Step.GROUP_TRACKED_UNTRACKED_MANUAL
)
return await self.handle_group_step(
GroupType.TRACKED_UNTRACKED,
@@ -438,7 +441,10 @@ class GroupConfigFlow(GroupFlow):
next_step=_next,
)
async def async_step_group_tracked_untracked_auto(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_tracked_untracked_auto(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
schema = await create_schema_tracked_untracked_auto(self.flow.hass)
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -449,7 +455,10 @@ class GroupConfigFlow(GroupFlow):
user_input,
)
async def async_step_group_tracked_untracked_manual(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_tracked_untracked_manual(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
schema = await create_schema_group_tracked_untracked_manual(self.flow.hass, user_input)
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -468,21 +477,23 @@ class GroupOptionsFlow(GroupFlow):
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the group options flow."""
return await self.flow.async_handle_options_step(
user_input, create_schema_group_custom(self.flow.hass, self.flow.config_entry, True), Step.GROUP_CUSTOM
user_input,
create_schema_group_custom(self.flow.hass, self.flow.config_entry, True),
Step.GROUP_CUSTOM,
)
async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the group options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_GROUP_DOMAIN_OPTIONS, Step.GROUP_DOMAIN)
async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the group options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_GROUP_SUBTRACT_OPTIONS, Step.GROUP_SUBTRACT)
async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the group options flow."""
schema = SCHEMA_GROUP_TRACKED_UNTRACKED
if self.flow.sensor_config.get(CONF_GROUP_TRACKED_AUTO, True):
@@ -1,9 +1,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector, translation
import voluptuous as vol
@@ -31,10 +30,23 @@ from custom_components.powercalc.discovery import (
)
from custom_components.powercalc.flow_helper.common import FlowType, PowercalcFormStep, Step
from custom_components.powercalc.flow_helper.dynamic_field_builder import build_dynamic_field_schema
from custom_components.powercalc.flow_helper.schema import SCHEMA_ENERGY_SENSOR_TOGGLE, SCHEMA_UTILITY_METER_TOGGLE, build_sub_profile_schema
from custom_components.powercalc.helpers import collect_placeholders, iter_related_entity_placeholders, resolve_related_entity_placeholder
from custom_components.powercalc.flow_helper.schema import (
SCHEMA_ENERGY_SENSOR_TOGGLE,
SCHEMA_UTILITY_METER_TOGGLE,
build_sub_profile_schema,
)
from custom_components.powercalc.helpers import (
collect_placeholders,
iter_related_entity_placeholders,
resolve_related_entity_placeholder,
)
from custom_components.powercalc.power_profile.library import ModelInfo, ProfileLibrary
from custom_components.powercalc.power_profile.power_profile import DEVICE_TYPE_DOMAIN, DOMAIN_DEVICE_TYPE_MAPPING, DiscoveryBy, PowerProfile
from custom_components.powercalc.power_profile.power_profile import (
DEVICE_TYPE_DOMAIN,
DOMAIN_DEVICE_TYPE_MAPPING,
DiscoveryBy,
PowerProfile,
)
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
@@ -67,7 +79,7 @@ class LibraryFlow:
async def async_step_manufacturer(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Ask the user to select the manufacturer."""
async def _create_schema() -> vol.Schema:
@@ -82,7 +94,10 @@ class LibraryFlow:
]
return vol.Schema(
{
vol.Required(CONF_MANUFACTURER, default=self.flow.sensor_config.get(CONF_MANUFACTURER)): selector.SelectSelector(
vol.Required(
CONF_MANUFACTURER,
default=self.flow.sensor_config.get(CONF_MANUFACTURER),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=manufacturers,
mode=selector.SelectSelectorMode.DROPDOWN,
@@ -104,7 +119,7 @@ class LibraryFlow:
async def async_step_model(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Ask the user to select the model."""
def _build_model_label(model_id: str, model_name: str) -> str:
@@ -139,10 +154,18 @@ class LibraryFlow:
self._get_library_discovery_by(),
)
]
model = self.flow.selected_profile.model if self.flow.selected_profile else self.flow.sensor_config.get(CONF_MODEL)
model = (
self.flow.selected_profile.model
if self.flow.selected_profile
else self.flow.sensor_config.get(CONF_MODEL)
)
return vol.Schema(
{
vol.Required(CONF_MODEL, description={"suggested_value": model}, default=model): selector.SelectSelector(
vol.Required(
CONF_MODEL,
description={"suggested_value": model},
default=model,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=models,
mode=selector.SelectSelectorMode.DROPDOWN,
@@ -162,26 +185,29 @@ class LibraryFlow:
user_input,
)
async def async_step_post_library(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
async def async_step_post_library(self, _: dict[str, Any] | None = None) -> ConfigFlowResult:
"""
Handles the logic after the user either selected manufacturer/model himself or confirmed autodiscovered.
Forwards to the next step in the flow.
"""
if not self.flow.selected_profile:
return self.flow.async_abort(reason="model_not_supported") # type:ignore # pragma: no cover
return self.flow.async_abort(reason="model_not_supported") # pragma: no cover
if Step.LIBRARY_CUSTOM_FIELDS not in self.flow.handled_steps and self.flow.selected_profile.has_custom_fields:
return await self.async_step_library_custom_fields()
if Step.AVAILABILITY_ENTITY not in self.flow.handled_steps and self.flow.selected_profile.discovery_by == DiscoveryBy.DEVICE:
if (
Step.AVAILABILITY_ENTITY not in self.flow.handled_steps
and self.flow.selected_profile.discovery_by == DiscoveryBy.DEVICE
):
result = await self.async_step_availability_entity()
if result:
return result
if Step.SUB_PROFILE not in self.flow.handled_steps and await self.flow.selected_profile.requires_manual_sub_profile_selection:
if (
Step.SUB_PROFILE not in self.flow.handled_steps
and await self.flow.selected_profile.requires_manual_sub_profile_selection
):
return await self.async_step_sub_profile()
if (
@@ -191,21 +217,26 @@ class LibraryFlow:
):
return await self.async_step_smart_switch()
if Step.FIXED not in self.flow.handled_steps and self.flow.selected_profile.needs_fixed_config: # pragma: no cover
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_fixed() # type:ignore
if (
Step.FIXED not in self.flow.handled_steps and self.flow.selected_profile.needs_fixed_config
): # pragma: no cover
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_fixed() # type: ignore[no-any-return]
if Step.LINEAR not in self.flow.handled_steps and self.flow.selected_profile.needs_linear_config:
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_linear() # type:ignore
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_linear() # type: ignore[no-any-return]
if Step.MULTI_SWITCH not in self.flow.handled_steps and self.flow.selected_profile.calculation_strategy == CalculationStrategy.MULTI_SWITCH:
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_multi_switch() # type:ignore
if (
Step.MULTI_SWITCH not in self.flow.handled_steps
and self.flow.selected_profile.calculation_strategy == CalculationStrategy.MULTI_SWITCH
):
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_multi_switch() # type: ignore[no-any-return]
return await self.flow.flow_handlers[FlowType.GROUP].async_step_assign_groups() # type:ignore
return await self.flow.flow_handlers[FlowType.GROUP].async_step_assign_groups() # type: ignore[no-any-return]
async def async_step_library_custom_fields(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_library_custom_fields(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for custom fields."""
async def _process_user_input(user_input: dict[str, Any]) -> dict[str, Any]:
def _process_user_input(user_input: dict[str, Any]) -> dict[str, Any]:
return {CONF_VARIABLES: user_input}
form_kwarg: dict[str, Any] | None = None
@@ -234,11 +265,11 @@ class LibraryFlow:
async def async_step_sub_profile(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the flow for sub profile selection."""
assert self.flow.selected_profile is not None
async def _validate(user_input: dict[str, Any]) -> dict[str, str]:
def _validate(user_input: dict[str, Any]) -> dict[str, str]:
return {CONF_MODEL: f"{self.flow.sensor_config.get(CONF_MODEL)}/{user_input.get(CONF_SUB_PROFILE)}"}
library = await ProfileLibrary.factory(self.flow.hass)
@@ -254,7 +285,11 @@ class LibraryFlow:
if remarks:
remarks = "\n\n" + remarks
step = Step.SUB_PROFILE_PER_DEVICE if self.flow.selected_profile.discovery_by == DiscoveryBy.DEVICE else Step.SUB_PROFILE
step = (
Step.SUB_PROFILE_PER_DEVICE
if self.flow.selected_profile.discovery_by == DiscoveryBy.DEVICE
else Step.SUB_PROFILE
)
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -275,16 +310,16 @@ class LibraryFlow:
async def async_step_sub_profile_per_device(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
return await self.async_step_sub_profile(user_input)
async def async_step_smart_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_smart_switch(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Asks the user for the power of connect appliance for the smart switch."""
if self.flow.selected_profile and not self.flow.selected_profile.needs_fixed_config:
return self.flow.persist_config_entry()
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
return {
CONF_SELF_USAGE_INCLUDED: user_input.get(CONF_SELF_USAGE_INCLUDED),
CONF_MODE: CalculationStrategy.FIXED,
@@ -303,7 +338,7 @@ class LibraryFlow:
user_input,
)
async def async_step_availability_entity(self, user_input: dict[str, Any] | None = None) -> FlowResult | None:
async def async_step_availability_entity(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult | None:
"""Handle the flow for availability entity."""
# Auto-resolve availability entity from profile placeholders
auto_entity = self._resolve_availability_entity()
@@ -377,7 +412,7 @@ class LibraryConfigFlow(LibraryFlow):
async def async_step_library_multi_profile(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult | ConfigFlowResult:
) -> ConfigFlowResult:
"""This step gets executed when multiple profiles are found for the source entity."""
if user_input is not None:
selected_model: str = user_input.get(CONF_MODEL) # type: ignore
@@ -426,7 +461,7 @@ class LibraryConfigFlow(LibraryFlow):
async def async_step_library(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
"""Try to autodiscover manufacturer/model first.
Ask the user to confirm this or forward to manual library selection.
"""
@@ -439,7 +474,7 @@ class LibraryConfigFlow(LibraryFlow):
return self._show_autodiscovered_profile_form()
async def _handle_library_confirmation(self, user_input: dict[str, Any]) -> FlowResult:
async def _handle_library_confirmation(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Handle the user's response to an autodiscovered library profile."""
if not user_input.get(CONF_CONFIRM_AUTODISCOVERED_MODEL) or not self.flow.selected_profile:
return await self.async_step_manufacturer()
@@ -454,27 +489,31 @@ class LibraryConfigFlow(LibraryFlow):
async def _async_autodiscover_profile(self) -> None:
"""Populate the selected profile from the source entity when possible."""
if not self.flow.source_entity or not self.flow.source_entity.entity_entry or self.flow.selected_profile is not None:
if (
not self.flow.source_entity
or not self.flow.source_entity.entity_entry
or self.flow.selected_profile is not None
):
return
self.flow.selected_profile = await get_power_profile_by_source_entity(self.flow.hass, self.flow.source_entity)
if self.flow.selected_profile is None and self.flow.source_entity.device_entry:
self.flow.selected_profile = await get_power_profile_by_source_device(self.flow.hass, self.flow.source_entity)
self.flow.selected_profile = await get_power_profile_by_source_device(
self.flow.hass,
self.flow.source_entity,
)
def _show_autodiscovered_profile_form(self) -> FlowResult:
def _show_autodiscovered_profile_form(self) -> ConfigFlowResult:
"""Show the confirmation form for an autodiscovered library profile."""
profile = self.flow.selected_profile
assert profile is not None
return cast(
FlowResult,
self.flow.async_show_form(
step_id=Step.LIBRARY,
description_placeholders=self._build_library_description_placeholders(profile),
data_schema=SCHEMA_POWER_AUTODISCOVERED,
errors={},
last_step=False,
),
return self.flow.async_show_form(
step_id=Step.LIBRARY,
description_placeholders=self._build_library_description_placeholders(profile),
data_schema=SCHEMA_POWER_AUTODISCOVERED,
errors={},
last_step=False,
)
def _build_library_description_placeholders(self, profile: PowerProfile) -> dict[str, Any]:
@@ -499,9 +538,19 @@ class LibraryConfigFlow(LibraryFlow):
def _get_profile_source(self, profile: PowerProfile) -> str:
"""Build the autodiscovery source description."""
translations = translation.async_get_cached_translations(self.flow.hass, self.flow.hass.config.language, "common", DOMAIN)
if profile.discovery_by == DiscoveryBy.DEVICE and self.flow.source_entity and self.flow.source_entity.device_entry:
return f"{translations.get(f'component.{DOMAIN}.common.source_device')}: {self.flow.source_entity.device_entry.name}"
translations = translation.async_get_cached_translations(
self.flow.hass,
self.flow.hass.config.language,
"common",
DOMAIN,
)
if (
profile.discovery_by == DiscoveryBy.DEVICE
and self.flow.source_entity
and self.flow.source_entity.device_entry
):
label = translations.get(f"component.{DOMAIN}.common.source_device")
return f"{label}: {self.flow.source_entity.device_entry.name}"
return f"{translations.get(f'component.{DOMAIN}.common.source_entity')}: {self.flow.source_entity_id}"
@@ -537,7 +586,7 @@ class LibraryOptionsFlow(LibraryFlow):
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def async_step_library_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_library_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
self.flow.is_library_flow = True
self.flow.selected_sub_profile = self.flow.selected_profile.sub_profile # type: ignore
@@ -5,8 +5,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_DEVICE, CONF_ENTITY_ID, CONF_NAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
import voluptuous as vol
@@ -39,7 +39,7 @@ class RealPowerConfigFlow:
def __init__(self, flow: PowercalcConfigFlow) -> None:
self.flow: PowercalcConfigFlow = flow
async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for real power sensor"""
self.flow.selected_sensor_type = SensorType.REAL_POWER
@@ -57,6 +57,6 @@ class RealPowerOptionsFlow:
def __init__(self, flow: PowercalcOptionsFlow) -> None:
self.flow: PowercalcOptionsFlow = flow
async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the real power options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_REAL_POWER_OPTIONS, Step.REAL_POWER)
@@ -4,8 +4,8 @@ from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITIES, CONF_ENTITY_ID, CONF_ID, CONF_NAME, CONF_PATH, Platform
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
import voluptuous as vol
@@ -17,6 +17,8 @@ from custom_components.powercalc.const import (
CONF_CALIBRATE,
CONF_CREATE_ENERGY_SENSOR,
CONF_CREATE_UTILITY_METERS,
CONF_FIXED,
CONF_FIXED_VALUE,
CONF_GAMMA_CURVE,
CONF_IGNORE_UNAVAILABLE_STATE,
CONF_MAX_POWER,
@@ -24,6 +26,7 @@ from custom_components.powercalc.const import (
CONF_MODE,
CONF_MULTIPLY_FACTOR,
CONF_MULTIPLY_FACTOR_STANDBY,
CONF_PLAYBOOK_ID,
CONF_PLAYBOOKS,
CONF_POWER,
CONF_POWER_OFF,
@@ -34,13 +37,25 @@ from custom_components.powercalc.const import (
CONF_STATE_TRIGGER,
CONF_STATES_POWER,
CONF_UNAVAILABLE_POWER,
CONF_VALUE,
DUMMY_ENTITY_ID,
CalculationStrategy,
SensorType,
)
from custom_components.powercalc.flow_helper.common import FlowType, PowercalcFormStep, Step, fill_schema_defaults
from custom_components.powercalc.flow_helper.common import (
FlowType,
PowercalcFormStep,
Step,
fill_schema_defaults,
unwrap_choose_selector,
wrap_choose_selector,
)
from custom_components.powercalc.flow_helper.flows.global_configuration import get_global_powercalc_config
from custom_components.powercalc.flow_helper.flows.library import SCHEMA_POWER_OPTIONS_LIBRARY, SCHEMA_POWER_SMART_SWITCH
from custom_components.powercalc.flow_helper.flows.library import (
SCHEMA_POWER_OPTIONS_LIBRARY,
SCHEMA_POWER_SMART_SWITCH,
)
from custom_components.powercalc.flow_helper.profile_preview import PREVIEW_NAME
from custom_components.powercalc.flow_helper.schema import (
SCHEMA_ENERGY_SENSOR_TOGGLE,
SCHEMA_SENSOR_ENERGY_OPTIONS,
@@ -76,23 +91,125 @@ SCHEMA_POWER_OPTIONS = vol.Schema(
},
)
STATES_POWER_SELECTOR = selector.ObjectSelector(
selector.ObjectSelectorConfig(
fields={
CONF_STATE: {"required": True, "selector": {"text": None}},
CONF_POWER: {"required": True, "selector": {"number": {"mode": "box", "step": "any"}}},
},
multiple=True,
label_field=CONF_STATE,
description_field=CONF_POWER,
),
)
FIXED_CHOICE_SELECTORS: dict[str, selector.ChooseSelectorChoiceConfig] = {
CONF_POWER: {"selector": {"number": {"mode": "box", "step": "any"}}},
CONF_POWER_TEMPLATE: {"selector": {"template": {}}},
CONF_STATES_POWER: {"selector": STATES_POWER_SELECTOR.serialize()["selector"]},
}
def order_choices_for_default(
choices: dict[str, selector.ChooseSelectorChoiceConfig],
default_choice: str | None,
) -> dict[str, selector.ChooseSelectorChoiceConfig]:
"""Put the default choice first because HA initializes choose selectors from the first choice."""
if default_choice not in choices:
return choices
return {
default_choice: choices[default_choice],
**{choice: config for choice, config in choices.items() if choice != default_choice},
}
def find_present_choice(form_data: dict[str, Any], choices: dict[str, list[str] | str]) -> str | None:
"""Find the first choice that has matching config data."""
for choice_id, mapping in choices.items():
keys = [mapping] if isinstance(mapping, str) else mapping
if any(key in form_data for key in keys):
return choice_id
return None
SCHEMA_POWER_FIXED = vol.Schema(
{
vol.Optional(CONF_POWER): vol.Coerce(float),
vol.Optional(CONF_POWER_TEMPLATE): selector.TemplateSelector(),
vol.Optional(CONF_STATES_POWER): selector.ObjectSelector(),
vol.Required(CONF_FIXED_VALUE): selector.ChooseSelector(
selector.ChooseSelectorConfig(
choices=FIXED_CHOICE_SELECTORS,
translation_key=CONF_FIXED_VALUE,
),
),
},
)
SCHEMA_POWER_LINEAR = vol.Schema(
{
vol.Optional(CONF_MIN_POWER): vol.Coerce(float),
vol.Optional(CONF_MAX_POWER): vol.Coerce(float),
vol.Optional(CONF_GAMMA_CURVE): vol.Coerce(float),
vol.Optional(CONF_CALIBRATE): selector.ObjectSelector(),
vol.Optional(CONF_MIN_POWER): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX, step="any"),
),
vol.Optional(CONF_MAX_POWER): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX, step="any"),
),
vol.Optional(CONF_GAMMA_CURVE): selector.NumberSelector(
selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX, step="any"),
),
vol.Optional(CONF_CALIBRATE): selector.ObjectSelector(
selector.ObjectSelectorConfig(
fields={
CONF_VALUE: {"required": True, "selector": {"number": {"mode": "box", "step": 1}}},
CONF_POWER: {"required": True, "selector": {"number": {"mode": "box", "step": "any"}}},
},
multiple=True,
label_field=CONF_VALUE,
description_field=CONF_POWER,
),
),
},
)
FIXED_CHOICES: dict[str, list[str] | str] = {
CONF_STATES_POWER: CONF_STATES_POWER,
CONF_POWER_TEMPLATE: CONF_POWER_TEMPLATE,
CONF_POWER: CONF_POWER,
}
def fixed_choice_key_from_validated_value(value: object) -> str:
"""Infer the fixed strategy config key from a validated ChooseSelector value."""
if isinstance(value, list):
return CONF_STATES_POWER
if isinstance(value, str):
return CONF_POWER_TEMPLATE
return CONF_POWER
def unwrap_strategy_user_input(strategy: CalculationStrategy, user_input: dict[str, Any]) -> dict[str, Any]:
"""Unwrap ChooseSelector wrappers and normalize list/dict shapes for strategy user input."""
if strategy == CalculationStrategy.FIXED:
unwrap_choose_selector(user_input, CONF_FIXED_VALUE, fixed_choice_key_from_validated_value)
if CONF_STATE_TRIGGER in user_input and isinstance(user_input[CONF_STATE_TRIGGER], list):
user_input[CONF_STATE_TRIGGER] = {
item[CONF_STATE]: item[CONF_PLAYBOOK_ID] for item in user_input[CONF_STATE_TRIGGER]
}
return user_input
def wrap_strategy_form_data(strategy: CalculationStrategy, form_data: dict[str, Any]) -> dict[str, Any]:
"""Wrap flat strategy config back into ChooseSelector form structure for display."""
if strategy == CalculationStrategy.FIXED:
form_data = wrap_choose_selector(form_data, CONF_FIXED_VALUE, FIXED_CHOICES, raw_value=True)
if CONF_STATE_TRIGGER in form_data and isinstance(form_data[CONF_STATE_TRIGGER], dict):
form_data = {
**form_data,
CONF_STATE_TRIGGER: [
{CONF_STATE: state, CONF_PLAYBOOK_ID: playbook_id}
for state, playbook_id in form_data[CONF_STATE_TRIGGER].items()
],
}
return form_data
SCHEMA_POWER_MULTI_SWITCH_MANUAL = vol.Schema(
{
vol.Required(CONF_POWER): vol.Coerce(float),
@@ -127,6 +244,8 @@ STRATEGY_STEP_MAPPING: dict[CalculationStrategy, Step] = {
CalculationStrategy.WLED: Step.WLED,
}
STRATEGIES_WITHOUT_PREVIEW = {CalculationStrategy.PLAYBOOK, CalculationStrategy.MULTI_SWITCH}
class VirtualPowerFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
@@ -139,13 +258,13 @@ class VirtualPowerFlow:
create_schema_func = f"create_schema_{self.flow.strategy.lower()}"
if hasattr(self, create_schema_func):
return await getattr(self, create_schema_func)() # type: ignore
return await getattr(self, create_schema_func)() # type: ignore[no-any-return]
return STRATEGY_SCHEMAS[self.flow.strategy]
async def create_schema_linear(self) -> vol.Schema:
"""Create the config schema for linear strategy."""
return SCHEMA_POWER_LINEAR.extend( # type: ignore
return SCHEMA_POWER_LINEAR.extend( # type: ignore[no-any-return]
{
vol.Optional(CONF_ATTRIBUTE): selector.AttributeSelector(
selector.AttributeSelectorConfig(
@@ -156,6 +275,21 @@ class VirtualPowerFlow:
},
)
async def create_schema_fixed(self) -> vol.Schema:
"""Create the config schema for fixed strategy."""
fixed_config = self.flow.sensor_config.get(CONF_FIXED, {})
default_choice = find_present_choice(fixed_config, FIXED_CHOICES) if isinstance(fixed_config, dict) else None
return vol.Schema(
{
vol.Required(CONF_FIXED_VALUE): selector.ChooseSelector(
selector.ChooseSelectorConfig(
choices=order_choices_for_default(FIXED_CHOICE_SELECTORS, default_choice),
translation_key=CONF_FIXED_VALUE,
),
),
},
)
async def create_schema_multi_switch(self) -> vol.Schema:
"""Create the config schema for multi switch strategy."""
@@ -180,31 +314,57 @@ class VirtualPowerFlow:
async def create_schema_playbook(self) -> vol.Schema:
"""Create the config schema for playbook strategy."""
base_path = Path(self.flow.hass.config.path("powercalc/playbooks"))
playbook_files = [str(p.relative_to(base_path)) for p in base_path.rglob("*") if p.is_file()]
def _find_playbook_files() -> list[str]:
base_path = Path(self.flow.hass.config.path("powercalc/playbooks"))
return [str(p.relative_to(base_path)) for p in base_path.rglob("*") if p.is_file()]
playbook_files = await self.flow.hass.async_add_executor_job(_find_playbook_files)
state_trigger_state_selector: dict[str, Any] = {"text": None}
if self.flow.source_entity_id and self.flow.source_entity_id != DUMMY_ENTITY_ID:
state_trigger_state_selector = {"state": {"entity_id": self.flow.source_entity_id}}
return vol.Schema(
{
vol.Optional(CONF_PLAYBOOKS): selector.ObjectSelector(
{
"multiple": True,
"description_field": CONF_PATH,
"label_field": CONF_ID,
"fields": {
selector.ObjectSelectorConfig(
fields={
CONF_ID: {
"required": True,
"selector": {"text": None},
},
CONF_PATH: {
"required": True,
"selector": {"select": {"options": playbook_files, "mode": "dropdown", "custom_value": True}},
"selector": {
"select": {"options": playbook_files, "mode": "dropdown", "custom_value": True},
},
},
},
},
multiple=True,
description_field=CONF_PATH,
label_field=CONF_ID,
),
),
vol.Optional(CONF_REPEAT): selector.BooleanSelector(),
vol.Optional(CONF_AUTOSTART): selector.TextSelector(),
vol.Optional(CONF_STATE_TRIGGER): selector.ObjectSelector(),
vol.Optional(CONF_STATE_TRIGGER): selector.ObjectSelector(
selector.ObjectSelectorConfig(
fields={
CONF_STATE: {
"required": True,
"selector": state_trigger_state_selector,
},
CONF_PLAYBOOK_ID: {
"required": True,
"selector": {"text": None},
},
},
multiple=True,
description_field=CONF_PLAYBOOK_ID,
label_field=CONF_STATE,
),
),
},
)
@@ -213,25 +373,25 @@ class VirtualPowerFlow:
strategy: CalculationStrategy,
user_input: dict[str, Any] | None = None,
validate: Callable[[dict[str, Any]], None] | None = None,
) -> FlowResult:
) -> ConfigFlowResult:
self.flow.strategy = strategy
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
user_input = unwrap_strategy_user_input(strategy, user_input)
if validate:
validate(user_input)
# Convert states_power from dict to list to preserve order
if CONF_STATES_POWER in user_input and isinstance(user_input[CONF_STATES_POWER], dict):
user_input[CONF_STATES_POWER] = [{CONF_STATE: state, CONF_POWER: power} for state, power in user_input[CONF_STATES_POWER].items()]
await self.flow.validate_strategy_config({strategy: user_input})
return {strategy: user_input}
schema = await self.create_strategy_schema()
description_placeholders = {}
if strategy == CalculationStrategy.WLED:
description_placeholders = {
"docs_uri": "https://docs.powercalc.nl/strategies/wled/",
}
description_placeholders = {
"docs_uri": f"https://docs.powercalc.nl/strategies/{strategy.value.replace('_', '-')}/",
}
form_kwarg: dict[str, Any] = {"description_placeholders": description_placeholders}
if strategy not in STRATEGIES_WITHOUT_PREVIEW:
form_kwarg["preview"] = PREVIEW_NAME
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -239,12 +399,12 @@ class VirtualPowerFlow:
schema=schema,
next_step=Step.ASSIGN_GROUPS,
validate_user_input=_validate,
form_kwarg={"description_placeholders": description_placeholders},
form_kwarg=form_kwarg,
),
user_input,
)
async def async_step_power_advanced(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_power_advanced(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for advanced options."""
if self.flow.is_options_flow:
@@ -302,9 +462,9 @@ class VirtualPowerConfigFlow(VirtualPowerFlow):
options_schema,
get_global_powercalc_config(self.flow),
)
return schema.extend(power_options.schema) # type: ignore
return schema.extend(power_options.schema) # type: ignore[no-any-return]
async def async_step_virtual_power(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_virtual_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for virtual power sensor."""
errors: dict[str, str] = {}
@@ -313,12 +473,16 @@ class VirtualPowerConfigFlow(VirtualPowerFlow):
user_input.get(CONF_MODE) or CalculationStrategy.LUT,
)
entity_id = user_input.get(CONF_ENTITY_ID)
if selected_strategy is not CalculationStrategy.PLAYBOOK and user_input.get(CONF_NAME) is None and entity_id is None:
if (
selected_strategy is not CalculationStrategy.PLAYBOOK
and user_input.get(CONF_NAME) is None
and entity_id is None
):
errors[CONF_ENTITY_ID] = "entity_mandatory"
if not errors:
self.flow.source_entity_id = str(entity_id or DUMMY_ENTITY_ID)
self.flow.source_entity = await create_source_entity(
self.flow.source_entity = create_source_entity(
self.flow.source_entity_id,
self.flow.hass,
)
@@ -329,33 +493,30 @@ class VirtualPowerConfigFlow(VirtualPowerFlow):
return await self.forward_to_strategy_step(selected_strategy)
return self.flow.async_show_form( # type: ignore
return self.flow.async_show_form(
step_id=Step.VIRTUAL_POWER,
data_schema=self.create_schema_virtual_power(),
description_placeholders={
"doc_uri_states_power": "https://docs.powercalc.nl/strategies/fixed/#power-per-state",
},
errors=errors,
last_step=False,
)
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for fixed sensor."""
return await self.handle_strategy_step(CalculationStrategy.FIXED, user_input)
async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for fixed sensor."""
return await self.handle_strategy_step(CalculationStrategy.LINEAR, user_input)
async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for multi switch strategy."""
return await self.handle_strategy_step(CalculationStrategy.MULTI_SWITCH, user_input)
async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for WLED sensor."""
return await self.handle_strategy_step(CalculationStrategy.WLED, user_input)
async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for playbook sensor."""
def _validate(user_input: dict[str, Any]) -> None:
@@ -364,13 +525,13 @@ class VirtualPowerConfigFlow(VirtualPowerFlow):
return await self.handle_strategy_step(CalculationStrategy.PLAYBOOK, user_input, _validate)
async def forward_to_strategy_step(self, strategy: CalculationStrategy) -> FlowResult:
async def forward_to_strategy_step(self, strategy: CalculationStrategy) -> ConfigFlowResult:
"""Forward to the next step based on the selected strategy."""
step = STRATEGY_STEP_MAPPING.get(strategy)
if step is None:
return await self.flow.flow_handlers[FlowType.LIBRARY].async_step_library() # type:ignore
return await self.flow.flow_handlers[FlowType.LIBRARY].async_step_library() # type: ignore[no-any-return]
method = getattr(self.flow, f"async_step_{step}")
return await method() # type: ignore
return await method() # type: ignore[no-any-return]
class VirtualPowerOptionsFlow(VirtualPowerFlow):
@@ -383,40 +544,52 @@ class VirtualPowerOptionsFlow(VirtualPowerFlow):
user_input: dict[str, Any],
) -> dict[str, Any]:
"""Build the config dict needed for the configured strategy."""
if self.flow.strategy:
user_input = unwrap_strategy_user_input(self.flow.strategy, dict(user_input))
strategy_schema = await self.create_strategy_schema()
strategy_options: dict[str, Any] = {}
flat_keys: set[str] = set()
for key in strategy_schema.schema:
base_key = key.schema if isinstance(key, vol.Marker) else key
flat_keys.add(str(base_key))
# The wrapper-key flat values land in user_input after unwrap; collect anything
# that matches a known strategy config key.
candidate_keys = flat_keys | {
CONF_POWER,
CONF_POWER_TEMPLATE,
CONF_STATES_POWER,
CONF_MIN_POWER,
CONF_MAX_POWER,
CONF_GAMMA_CURVE,
CONF_CALIBRATE,
}
for key in candidate_keys:
if user_input.get(key) is None:
continue
strategy_options[str(key)] = user_input.get(key)
# Convert states_power from dict to list to preserve order
if CONF_STATES_POWER in strategy_options and isinstance(strategy_options[CONF_STATES_POWER], dict):
strategy_options[CONF_STATES_POWER] = [
{CONF_STATE: state, CONF_POWER: power} for state, power in strategy_options[CONF_STATES_POWER].items()
]
strategy_options[key] = user_input[key]
return strategy_options
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_handle_strategy_options_step(self, user_input: dict[str, Any] | None = None) -> FlowResult:
async def async_handle_strategy_options_step(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the option processing for the selected strategy."""
step = STRATEGY_STEP_MAPPING.get(self.flow.strategy or CalculationStrategy.FIXED, Step.FIXED)
@@ -429,8 +602,13 @@ class VirtualPowerOptionsFlow(VirtualPowerFlow):
**self.flow.sensor_config,
**{k: v for k, v in strategy_options.items() if k not in self.flow.sensor_config},
}
# Convert states_power from list to dict for display in ObjectSelector
if CONF_STATES_POWER in merged_options and isinstance(merged_options[CONF_STATES_POWER], list):
merged_options[CONF_STATES_POWER] = {item[CONF_STATE]: item[CONF_POWER] for item in merged_options[CONF_STATES_POWER]}
if self.flow.strategy:
merged_options = wrap_strategy_form_data(self.flow.strategy, merged_options)
schema = fill_schema_defaults(schema, merged_options)
return await self.flow.async_handle_options_step(user_input, schema, step)
form_kwarg = {"preview": PREVIEW_NAME} if self.flow.strategy not in STRATEGIES_WITHOUT_PREVIEW else None
return await self.flow.async_handle_options_step(
user_input,
schema,
step,
form_kwarg=form_kwarg,
)
@@ -0,0 +1,199 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any, Protocol, cast
from homeassistant.components import websocket_api
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import UnknownFlow
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.const import CONF_FIXED_VALUE, CalculationStrategy
from custom_components.powercalc.errors import StrategyConfigurationError, UnsupportedStrategyError
from custom_components.powercalc.flow_helper.common import unwrap_choose_selector
from custom_components.powercalc.power_profile.power_profile import PowerProfile
from custom_components.powercalc.strategy.factory import PowerCalculatorStrategyFactory
from custom_components.powercalc.strategy.selector import detect_calculation_strategy
PREVIEW_NAME = "powercalc"
PREVIEW_FRIENDLY_NAME = "Preview power"
PREVIEW_ICON = "mdi:flash"
class PreviewFlowProtocol(Protocol):
sensor_config: ConfigType
selected_profile: PowerProfile | None
source_entity: SourceEntity | None
async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up the Powercalc preview websocket command."""
websocket_api.async_register_command(hass, ws_start_preview)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{PREVIEW_NAME}/start_preview",
vol.Required("flow_id"): str,
vol.Required("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Required("user_input"): dict,
},
)
@websocket_api.async_response
async def ws_start_preview(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Generate a live Powercalc strategy preview."""
flow = _get_flow_handler(hass, msg)
flow_status = _get_flow_status(hass, msg)
errors = _validate_user_input(flow_status.get("data_schema"), msg["user_input"])
if errors:
connection.send_message(
{
"id": msg["id"],
"type": websocket_api.TYPE_RESULT,
"success": False,
"error": {"code": "invalid_user_input", "message": errors},
},
)
return
source_entity = flow.source_entity
if source_entity is None:
raise HomeAssistantError("No source entity available for Powercalc preview")
preview = await build_profile_preview(
hass,
_build_preview_sensor_config(flow, flow_status["step_id"], msg["user_input"]),
source_entity,
flow.selected_profile,
)
connection.send_result(msg["id"])
connection.send_message(
websocket_api.event_message(
msg["id"],
{
"attributes": preview["attributes"],
"state": preview["state"],
},
),
)
connection.subscriptions[msg["id"]] = lambda: None
def _get_flow_handler(hass: HomeAssistant, msg: dict[str, Any]) -> PreviewFlowProtocol:
manager = hass.config_entries.flow if msg["flow_type"] == "config_flow" else hass.config_entries.options
try:
return cast(PreviewFlowProtocol, manager._progress[msg["flow_id"]]) # noqa: SLF001
except KeyError as err:
raise UnknownFlow from err
def _get_flow_status(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str, Any]:
manager = hass.config_entries.flow if msg["flow_type"] == "config_flow" else hass.config_entries.options
return cast(dict[str, Any], manager.async_get(msg["flow_id"]))
def _validate_user_input(schema: vol.Schema | None, user_input: dict[str, Any]) -> dict[str, str]:
if schema is None:
return {}
errors: dict[str, str] = {}
key: vol.Marker
for key, validator in schema.schema.items():
if key.schema not in user_input:
continue
try:
validator(user_input[key.schema])
except vol.Invalid as ex:
errors[str(key.schema)] = str(ex.msg)
return errors
def _build_preview_sensor_config(flow: PreviewFlowProtocol, step_id: str, user_input: dict[str, Any]) -> ConfigType:
sensor_config = dict(flow.sensor_config)
try:
strategy = CalculationStrategy(step_id)
except ValueError:
return sensor_config
sensor_config[strategy] = _unwrap_preview_strategy_input(strategy, user_input)
return sensor_config
def _unwrap_preview_strategy_input(strategy: CalculationStrategy, user_input: dict[str, Any]) -> dict[str, Any]:
"""Unwrap form-only selector wrappers before building a preview strategy config."""
unwrapped = dict(user_input)
if strategy == CalculationStrategy.FIXED:
unwrap_choose_selector(unwrapped, CONF_FIXED_VALUE)
return unwrapped
async def build_profile_preview(
hass: HomeAssistant,
sensor_config: ConfigType,
source_entity: SourceEntity,
power_profile: PowerProfile | None,
) -> dict[str, Any]:
"""Build an entity-like preview containing only current calculated power."""
current_power = await _calculate_current_power(hass, sensor_config, source_entity, power_profile)
return {
"attributes": {
ATTR_FRIENDLY_NAME: PREVIEW_FRIENDLY_NAME,
ATTR_ICON: PREVIEW_ICON,
},
"state": _format_preview_state(current_power),
}
async def _calculate_current_power(
hass: HomeAssistant,
sensor_config: ConfigType,
source_entity: SourceEntity,
power_profile: PowerProfile | None,
) -> Decimal | None:
current_state = hass.states.get(source_entity.entity_id)
if current_state is None:
return None
try:
cv.template_complex(sensor_config)
except vol.Invalid:
return None
strategy = detect_calculation_strategy(sensor_config, power_profile)
try:
calculation_strategy = await PowerCalculatorStrategyFactory(hass).create(
sensor_config,
strategy,
power_profile,
source_entity,
)
except (StrategyConfigurationError, UnsupportedStrategyError):
return None
try:
return await calculation_strategy.calculate(current_state)
except HomeAssistantError:
return None
def _format_preview_state(power: Decimal | None) -> str:
if power is None:
return "unavailable"
return f"{_format_power(power)} W"
def _format_power(value: Decimal | float | str | None) -> str:
if value is None:
return "0"
decimal_value = Decimal(str(value))
return f"{decimal_value:.2f}".rstrip("0").rstrip(".")