Initil after Upgrade
This commit is contained in:
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(".")
|
||||
Reference in New Issue
Block a user