Initial Commit

This commit is contained in:
2026-06-11 11:50:50 -04:00
commit d4a69c41be
2748 changed files with 80489 additions and 0 deletions
@@ -0,0 +1,96 @@
from collections.abc import Callable, Coroutine
import copy
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
import voluptuous as vol
class Step(StrEnum):
ADVANCED_OPTIONS = "advanced_options"
ASSIGN_GROUPS = "assign_groups"
AVAILABILITY_ENTITY = "availability_entity"
BASIC_OPTIONS = "basic_options"
GROUP_CUSTOM = "group_custom"
GROUP_DOMAIN = "group_domain"
GROUP_SUBTRACT = "group_subtract"
GROUP_TRACKED_UNTRACKED = "group_tracked_untracked"
GROUP_TRACKED_UNTRACKED_AUTO = "group_tracked_untracked_auto"
GROUP_TRACKED_UNTRACKED_MANUAL = "group_tracked_untracked_manual"
LIBRARY = "library"
POST_LIBRARY = "post_library"
LIBRARY_CUSTOM_FIELDS = "library_custom_fields"
LIBRARY_MULTI_PROFILE = "library_multi_profile"
LIBRARY_OPTIONS = "library_options"
VIRTUAL_POWER = "virtual_power"
FIXED = "fixed"
LINEAR = "linear"
MULTI_SWITCH = "multi_switch"
PLAYBOOK = "playbook"
WLED = "wled"
POWER_ADVANCED = "power_advanced"
DAILY_ENERGY = "daily_energy"
REAL_POWER = "real_power"
MANUFACTURER = "manufacturer"
MENU_LIBRARY = "menu_library"
MENU_GROUP = "menu_group"
MODEL = "model"
SUB_PROFILE = "sub_profile"
SUB_PROFILE_PER_DEVICE = "sub_profile_per_device"
USER = "user"
SMART_SWITCH = "smart_switch"
INIT = "init"
ENERGY_OPTIONS = "energy_options"
UTILITY_METER_OPTIONS = "utility_meter_options"
GLOBAL_CONFIGURATION = "global_configuration"
GLOBAL_CONFIGURATION_DISCOVERY = "global_configuration_discovery"
GLOBAL_CONFIGURATION_ENERGY = "global_configuration_energy"
GLOBAL_CONFIGURATION_THROTTLING = "global_configuration_throttling"
GLOBAL_CONFIGURATION_UTILITY_METER = "global_configuration_utility_meter"
class FlowType(StrEnum):
VIRTUAL_POWER = "virtual_power"
DAILY_ENERGY = "daily_energy"
REAL_POWER = "real_power"
LIBRARY = "library"
GROUP = "group"
GLOBAL_CONFIGURATION = "global_configuration"
@dataclass(slots=True)
class PowercalcFormStep:
schema: vol.Schema | Callable[[], Coroutine[Any, Any, vol.Schema | None]]
step: Step
validate_user_input: (
Callable[
[dict[str, Any]],
Coroutine[Any, Any, dict[str, Any]],
]
| None
) = None
next_step: Step | Callable[[dict[str, Any]], Coroutine[Any, Any, Step | None]] | None = None
continue_utility_meter_options_step: bool = False
continue_advanced_step: bool = False
form_kwarg: dict[str, Any] | None = None
form_data: dict[str, Any] | None = None
def fill_schema_defaults(
data_schema: vol.Schema,
options: dict[str, Any],
) -> vol.Schema:
"""Make a copy of the schema with suggested values set to saved options."""
schema = {}
for key, val in data_schema.schema.items():
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
elif "suggested_value" not in (new_key.description or {}):
new_key = copy.copy(key)
new_key.description = {"suggested_value": options.get(key)} # type: ignore
schema[new_key] = val
return vol.Schema(schema)
@@ -0,0 +1,29 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.selector import selector
import voluptuous as vol
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:
schema = {}
for field in profile.custom_fields:
field_description = field.description
if not field_description:
field_description = field.label
if field.default is not None:
key = vol.Required(field.key, description=field_description, default=field.default)
else:
key = vol.Required(field.key, description=field_description)
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)
]
schema[key] = selector(field.selector)
return vol.Schema(schema)
@@ -0,0 +1,113 @@
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.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_FIXED_ENERGY,
CONF_GROUP,
CONF_ON_TIME,
CONF_UPDATE_FREQUENCY,
CONF_VALUE,
CONF_VALUE_TEMPLATE,
SensorType,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, fill_schema_defaults
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
SCHEMA_DAILY_ENERGY_OPTIONS = vol.Schema(
{
vol.Optional(CONF_VALUE): vol.Coerce(float),
vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(),
vol.Optional(
CONF_UNIT_OF_MEASUREMENT,
default=UnitOfEnergy.KILO_WATT_HOUR,
): vol.In(
[UnitOfEnergy.KILO_WATT_HOUR, UnitOfPower.WATT],
),
vol.Optional(CONF_ON_TIME): selector.DurationSelector(
selector.DurationSelectorConfig(enable_day=False),
),
vol.Optional(
CONF_UPDATE_FREQUENCY,
default=DEFAULT_DAILY_UPDATE_FREQUENCY,
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=10,
unit_of_measurement=UnitOfTime.SECONDS,
mode=selector.NumberSelectorMode.BOX,
),
),
},
)
SCHEMA_DAILY_ENERGY = vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
).extend(SCHEMA_DAILY_ENERGY_OPTIONS.schema)
def build_daily_energy_config(user_input: dict[str, Any], schema: vol.Schema) -> dict[str, Any]:
"""Build the config under daily_energy: key."""
config: dict[str, Any] = {
CONF_DAILY_FIXED_ENERGY: {},
}
for key, val in user_input.items():
if key in schema.schema and val is not None:
if key in {CONF_CREATE_UTILITY_METERS, CONF_GROUP, CONF_NAME, CONF_UNIQUE_ID}:
config[str(key)] = val
continue
config[CONF_DAILY_FIXED_ENERGY][str(key)] = val
return config
class DailyEnergyConfigFlow:
def __init__(self, flow: PowercalcConfigFlow) -> None:
self.flow: PowercalcConfigFlow = flow
async def async_step_daily_energy(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""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:
raise SchemaFlowError("daily_energy_mandatory")
return build_daily_energy_config(user_input, SCHEMA_DAILY_ENERGY)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.DAILY_ENERGY,
schema=SCHEMA_DAILY_ENERGY,
validate_user_input=_validate,
next_step=Step.ASSIGN_GROUPS,
),
user_input,
)
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:
"""Handle the daily energy options flow."""
schema = fill_schema_defaults(
SCHEMA_DAILY_ENERGY_OPTIONS,
self.flow.sensor_config[CONF_DAILY_FIXED_ENERGY],
)
return await self.flow.async_handle_options_step(user_input, schema, Step.DAILY_ENERGY)
@@ -0,0 +1,287 @@
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING, Any
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
from custom_components.powercalc import DeviceType
from custom_components.powercalc.const import (
CONF_CREATE_ENERGY_SENSORS,
CONF_CREATE_STANDBY_GROUP,
CONF_CREATE_UTILITY_METERS,
CONF_DISABLE_EXTENDED_ATTRIBUTES,
CONF_DISABLE_LIBRARY_DOWNLOAD,
CONF_DISCOVERY,
CONF_ENABLE_ANALYTICS,
CONF_ENERGY_SENSOR_CATEGORY,
CONF_ENERGY_SENSOR_FRIENDLY_NAMING,
CONF_ENERGY_SENSOR_NAMING,
CONF_ENERGY_SENSOR_PRECISION,
CONF_ENERGY_UPDATE_INTERVAL,
CONF_EXCLUDE_DEVICE_TYPES,
CONF_EXCLUDE_SELF_USAGE,
CONF_GROUP_ENERGY_UPDATE_INTERVAL,
CONF_GROUP_POWER_UPDATE_INTERVAL,
CONF_IGNORE_UNAVAILABLE_STATE,
CONF_INCLUDE_NON_POWERCALC_SENSORS,
CONF_POWER_SENSOR_CATEGORY,
CONF_POWER_SENSOR_FRIENDLY_NAMING,
CONF_POWER_SENSOR_NAMING,
CONF_POWER_SENSOR_PRECISION,
CONF_POWER_UPDATE_INTERVAL,
CONF_UTILITY_METER_OFFSET,
DEFAULT_ENERGY_UPDATE_INTERVAL,
DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL,
DEFAULT_GROUP_POWER_UPDATE_INTERVAL,
DOMAIN,
DOMAIN_CONFIG,
ENTITY_CATEGORIES,
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
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
SCHEMA_GLOBAL_CONFIGURATION = vol.Schema(
{
vol.Optional(CONF_POWER_SENSOR_NAMING): selector.TextSelector(),
vol.Optional(CONF_POWER_SENSOR_FRIENDLY_NAMING): selector.TextSelector(),
vol.Optional(CONF_POWER_SENSOR_CATEGORY): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(filter(lambda item: item is not None, ENTITY_CATEGORIES)), # type: ignore
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
vol.Optional(CONF_POWER_SENSOR_PRECISION): selector.NumberSelector(
selector.NumberSelectorConfig(min=0, max=6, mode=selector.NumberSelectorMode.BOX, step=1),
),
vol.Optional(CONF_ENABLE_ANALYTICS, default=True): selector.BooleanSelector(),
vol.Optional(CONF_IGNORE_UNAVAILABLE_STATE, default=False): selector.BooleanSelector(),
vol.Optional(CONF_INCLUDE_NON_POWERCALC_SENSORS, default=True): selector.BooleanSelector(),
vol.Optional(CONF_DISABLE_EXTENDED_ATTRIBUTES, default=False): selector.BooleanSelector(),
vol.Optional(CONF_DISABLE_LIBRARY_DOWNLOAD, default=False): selector.BooleanSelector(),
vol.Optional(CONF_CREATE_STANDBY_GROUP, default=True): selector.BooleanSelector(),
vol.Optional(CONF_CREATE_ENERGY_SENSORS, default=True): selector.BooleanSelector(),
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_GLOBAL_CONFIGURATION_DISCOVERY = vol.Schema(
{
vol.Optional(CONF_ENABLED, default=True): selector.BooleanSelector(),
vol.Optional(CONF_EXCLUDE_DEVICE_TYPES): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[cls.value for cls in DeviceType],
mode=selector.SelectSelectorMode.DROPDOWN,
multiple=True,
),
),
vol.Optional(CONF_EXCLUDE_SELF_USAGE, default=False): selector.BooleanSelector(),
},
)
SCHEMA_GLOBAL_CONFIGURATION_THROTTLING = vol.Schema(
{
vol.Optional(CONF_POWER_UPDATE_INTERVAL, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
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(
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(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
},
)
SCHEMA_GLOBAL_CONFIGURATION_ENERGY_SENSOR = vol.Schema(
{
vol.Optional(CONF_ENERGY_SENSOR_NAMING): selector.TextSelector(),
vol.Optional(CONF_ENERGY_SENSOR_FRIENDLY_NAMING): selector.TextSelector(),
vol.Optional(CONF_ENERGY_SENSOR_CATEGORY): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(filter(lambda item: item is not None, ENTITY_CATEGORIES)), # type: ignore
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
**SCHEMA_ENERGY_OPTIONS.schema,
vol.Optional(CONF_ENERGY_SENSOR_PRECISION): selector.NumberSelector(
selector.NumberSelectorConfig(min=0, max=6, mode=selector.NumberSelectorMode.BOX, step=1),
),
},
)
def get_global_powercalc_config(flow: PowercalcCommonFlow) -> ConfigType:
"""Get the global powercalc config."""
if flow.global_config:
return flow.global_config
powercalc = flow.hass.data.get(DOMAIN) or {}
global_config = dict.copy(powercalc.get(DOMAIN_CONFIG) or {})
utility_meter_offset = global_config.get(CONF_UTILITY_METER_OFFSET)
if isinstance(utility_meter_offset, timedelta):
global_config[CONF_UTILITY_METER_OFFSET] = utility_meter_offset.days
if CONF_SENSORS in global_config:
global_config.pop(CONF_SENSORS)
flow.global_config = global_config
return global_config
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:
"""Handle the discovery configuration step."""
if user_input is not None:
self.flow.global_config.update({CONF_DISCOVERY: user_input})
if self.flow.is_options_flow:
return self.flow.persist_config_entry()
global_config = get_global_powercalc_config(self.flow)
discovery_options: dict[str, Any] = global_config.get(CONF_DISCOVERY, {})
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_DISCOVERY,
schema=SCHEMA_GLOBAL_CONFIGURATION_DISCOVERY,
next_step=Step.GLOBAL_CONFIGURATION_THROTTLING,
form_data=discovery_options,
form_kwarg={"description_placeholders": {"docs_uri": "https://docs.powercalc.nl/library/discovery/"}},
),
user_input,
)
async def async_step_global_configuration_throttling(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the throttling related options."""
if user_input is not None:
self.flow.global_config.update(user_input)
if self.flow.is_options_flow:
return self.flow.persist_config_entry()
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_THROTTLING,
schema=SCHEMA_GLOBAL_CONFIGURATION_THROTTLING,
next_step=Step.GLOBAL_CONFIGURATION_ENERGY,
form_kwarg={
"description_placeholders": {
"docs_uri": "https://docs.powercalc.nl/configuration/update-frequency/",
},
},
),
user_input,
)
async def async_step_global_configuration_energy(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the global configuration step."""
if user_input is not None:
self.flow.global_config.update(user_input)
if self.flow.is_options_flow:
return self.flow.persist_config_entry()
if not bool(self.flow.global_config.get(CONF_CREATE_ENERGY_SENSORS)) or user_input is not None:
return await self.async_step_global_configuration_utility_meter()
return await self.flow.handle_form_step(
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/"}},
),
)
async def async_step_global_configuration_utility_meter(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the global configuration step."""
if user_input is not None:
self.flow.global_config.update(user_input)
if self.flow.is_options_flow:
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
title="Global Configuration",
data=self.flow.global_config,
)
return await self.flow.handle_form_step(
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/"}},
),
)
class GlobalConfigurationConfigFlow(GlobalConfigurationFlow):
def __init__(self, flow: PowercalcConfigFlow) -> None:
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the global configuration step."""
get_global_powercalc_config(self.flow)
await self.flow.async_set_unique_id(ENTRY_GLOBAL_CONFIG_UNIQUE_ID)
self.flow.abort_if_unique_id_configured()
if user_input is not None:
self.flow.global_config.update(user_input)
return await self.async_step_global_configuration_discovery()
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION,
schema=SCHEMA_GLOBAL_CONFIGURATION,
form_kwarg={
"description_placeholders": {
"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/",
},
},
),
)
class GlobalConfigurationOptionsFlow(GlobalConfigurationFlow):
def __init__(self, flow: PowercalcOptionsFlow) -> None:
super().__init__(flow)
self.flow = flow
def build_global_config_menu(self) -> dict[Step, str]:
"""Build menu for global configuration"""
menu = {
Step.GLOBAL_CONFIGURATION: "Basic options",
Step.GLOBAL_CONFIGURATION_DISCOVERY: "Discovery options",
Step.GLOBAL_CONFIGURATION_THROTTLING: "Throttling options",
}
if self.flow.global_config.get(CONF_CREATE_ENERGY_SENSORS):
menu[Step.GLOBAL_CONFIGURATION_ENERGY] = "Energy options"
if self.flow.global_config.get(CONF_CREATE_UTILITY_METERS):
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:
"""Handle the global configuration step."""
if user_input is not None:
self.flow.global_config.update(user_input)
return self.flow.persist_config_entry()
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION,
schema=SCHEMA_GLOBAL_CONFIGURATION,
),
)
@@ -0,0 +1,504 @@
"""Group-related logic for the config flow."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.const import (
CONF_DEVICE,
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_NAME,
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
import voluptuous as vol
from custom_components.powercalc.const import (
CONF_AREA,
CONF_EXCLUDE_ENTITIES,
CONF_FLOOR,
CONF_FORCE_CALCULATE_GROUP_ENERGY,
CONF_GROUP,
CONF_GROUP_ENERGY_ENTITIES,
CONF_GROUP_ENERGY_START_AT_ZERO,
CONF_GROUP_MEMBER_DEVICES,
CONF_GROUP_MEMBER_SENSORS,
CONF_GROUP_POWER_ENTITIES,
CONF_GROUP_TRACKED_AUTO,
CONF_GROUP_TRACKED_POWER_ENTITIES,
CONF_GROUP_TYPE,
CONF_HIDE_MEMBERS,
CONF_INCLUDE_NON_POWERCALC_SENSORS,
CONF_MAIN_POWER_SENSOR,
CONF_NEW_GROUP,
CONF_SENSOR_TYPE,
CONF_SUB_GROUPS,
CONF_SUBTRACT_ENTITIES,
DOMAIN,
GroupType,
SensorType,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, fill_schema_defaults
from custom_components.powercalc.flow_helper.schema import SCHEMA_ENERGY_SENSOR_TOGGLE, SCHEMA_UTILITY_METER_TOGGLE
from custom_components.powercalc.group_include.include import find_entities
from custom_components.powercalc.sensors.group.config_entry_utils import get_group_entries
from custom_components.powercalc.sensors.group.tracked_untracked import find_auto_tracked_power_entities
from custom_components.powercalc.sensors.power import PowerSensor
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
# Constants
UNIQUE_ID_TRACKED_UNTRACKED = "pc_tracked_untracked"
# Schemas
SCHEMA_GROUP = vol.Schema(
{
vol.Required(CONF_NAME): str,
vol.Optional(CONF_DEVICE): selector.DeviceSelector(),
},
)
SCHEMA_GROUP_DOMAIN_OPTIONS = vol.Schema(
{
vol.Required(CONF_DOMAIN): selector.SelectSelector(
selector.SelectSelectorConfig(
options=["all"] + [cls.value for cls in Platform],
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
vol.Optional(CONF_EXCLUDE_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=[SensorDeviceClass.ENERGY, SensorDeviceClass.POWER],
multiple=True,
),
),
},
)
SCHEMA_GROUP_DOMAIN = vol.Schema(
{
vol.Required(CONF_NAME): str,
**SCHEMA_GROUP_DOMAIN_OPTIONS.schema,
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_GROUP_SUBTRACT_OPTIONS = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
multiple=False,
),
),
vol.Optional(CONF_SUBTRACT_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
multiple=True,
),
),
},
)
SCHEMA_GROUP_SUBTRACT = vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
**SCHEMA_GROUP_SUBTRACT_OPTIONS.schema,
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_GROUP_TRACKED_UNTRACKED = vol.Schema(
{
vol.Optional(CONF_MAIN_POWER_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
),
),
vol.Required(CONF_GROUP_TRACKED_AUTO): selector.BooleanSelector(),
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_GROUP_TRACKED_UNTRACKED_MANUAL = vol.Schema(
{
vol.Required(CONF_GROUP_TRACKED_POWER_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
multiple=True,
),
),
},
)
MENU_GROUP = [
Step.GROUP_CUSTOM,
Step.GROUP_DOMAIN,
Step.GROUP_SUBTRACT,
Step.GROUP_TRACKED_UNTRACKED,
]
# Mappings
GROUP_SCHEMAS: dict[GroupType, vol.Schema] = {
GroupType.CUSTOM: SCHEMA_GROUP,
GroupType.DOMAIN: SCHEMA_GROUP_DOMAIN,
GroupType.SUBTRACT: SCHEMA_GROUP_SUBTRACT,
GroupType.TRACKED_UNTRACKED: SCHEMA_GROUP_TRACKED_UNTRACKED,
}
GROUP_STEP_MAPPING: dict[GroupType, Step] = {
GroupType.CUSTOM: Step.GROUP_CUSTOM,
GroupType.DOMAIN: Step.GROUP_DOMAIN,
GroupType.STANDBY: Step.GROUP_DOMAIN,
GroupType.SUBTRACT: Step.GROUP_SUBTRACT,
GroupType.TRACKED_UNTRACKED: Step.GROUP_TRACKED_UNTRACKED,
}
def validate_group_input(user_input: dict[str, Any] | None = None) -> None:
"""Validate the group form."""
required_keys = {
CONF_SUB_GROUPS,
CONF_GROUP_POWER_ENTITIES,
CONF_GROUP_ENERGY_ENTITIES,
CONF_GROUP_MEMBER_SENSORS,
CONF_GROUP_MEMBER_DEVICES,
CONF_AREA,
CONF_FLOOR,
}
if not any(key in (user_input or {}) for key in required_keys):
raise SchemaFlowError("group_mandatory")
def create_schema_group_custom(
hass: HomeAssistant,
config_entry: ConfigEntry | None = None,
is_option_flow: bool = False,
) -> vol.Schema:
"""Create config schema for groups."""
member_sensors = [
selector.SelectOptionDict(value=config_entry.entry_id, label=config_entry.title)
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.data.get(CONF_SENSOR_TYPE) in [SensorType.VIRTUAL_POWER, SensorType.REAL_POWER]
and config_entry.unique_id is not None
and config_entry.title is not None
]
member_sensor_selector = selector.SelectSelector(
selector.SelectSelectorConfig(
options=member_sensors,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
),
)
schema = vol.Schema(
{
vol.Optional(CONF_GROUP_MEMBER_SENSORS): member_sensor_selector,
vol.Optional(CONF_GROUP_MEMBER_DEVICES): selector.DeviceSelector(
selector.DeviceSelectorConfig(
multiple=True,
entity=selector.EntityFilterSelectorConfig(
device_class=[SensorDeviceClass.POWER, SensorDeviceClass.ENERGY],
),
),
),
vol.Optional(CONF_GROUP_POWER_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
multiple=True,
),
),
vol.Optional(CONF_GROUP_ENERGY_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.ENERGY,
multiple=True,
),
),
vol.Optional(CONF_SUB_GROUPS): create_group_selector(hass, current_entry=config_entry),
vol.Optional(CONF_AREA): selector.AreaSelector(),
vol.Optional(CONF_FLOOR): selector.FloorSelector(),
vol.Optional(CONF_DEVICE): selector.DeviceSelector(),
vol.Optional(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(),
vol.Optional(CONF_INCLUDE_NON_POWERCALC_SENSORS, default=True): selector.BooleanSelector(),
vol.Optional(CONF_FORCE_CALCULATE_GROUP_ENERGY, default=False): selector.BooleanSelector(),
},
)
if not is_option_flow:
schema = schema.extend(
{
vol.Optional(CONF_GROUP_ENERGY_START_AT_ZERO, default=True): selector.BooleanSelector(),
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
return schema
def create_group_selector(
hass: HomeAssistant,
current_entry: ConfigEntry | None = None,
group_entries: list[ConfigEntry] | None = None,
) -> selector.SelectSelector:
"""Create the group selector."""
options = [
selector.SelectOptionDict(
value=config_entry.entry_id,
label=config_entry.title,
)
for config_entry in (group_entries or get_group_entries(hass, GroupType.CUSTOM))
if current_entry is None or config_entry.entry_id != current_entry.entry_id
]
return selector.SelectSelector(
selector.SelectSelectorConfig(
options=options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=True,
),
)
async def create_schema_tracked_untracked_auto(hass: HomeAssistant) -> vol.Schema:
"""Handle the flow for tracked/untracked group sensor."""
tracked_entities = await find_auto_tracked_power_entities(hass)
return vol.Schema(
{
vol.Optional(CONF_EXCLUDE_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
multiple=True,
include_entities=list(tracked_entities),
),
),
},
)
async def create_schema_group_tracked_untracked_manual(
hass: HomeAssistant,
user_input: dict[str, Any] | None = None,
schema: vol.Schema | None = None,
) -> vol.Schema:
"""Handle the flow for tracked/untracked group sensor."""
if not schema:
schema = SCHEMA_GROUP_TRACKED_UNTRACKED_MANUAL
if not user_input:
result = await find_entities(hass)
tracked_entities = [entity.entity_id for entity in result.resolved if isinstance(entity, PowerSensor)]
schema = fill_schema_defaults(schema, {CONF_GROUP_TRACKED_POWER_ENTITIES: tracked_entities})
return schema
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:
"""Handle the flow for assigning groups."""
group_entries = get_group_entries(self.flow.hass, GroupType.CUSTOM)
if not group_entries:
return await self.flow.handle_final_steps()
schema = vol.Schema(
{
vol.Optional(CONF_GROUP): create_group_selector(self.flow.hass, group_entries=group_entries),
vol.Optional(CONF_NEW_GROUP): TextSelector(),
},
)
async 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:
groups.append(new_group)
return {CONF_GROUP: groups}
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.ASSIGN_GROUPS,
schema=schema,
continue_advanced_step=True,
continue_utility_meter_options_step=True,
validate_user_input=_validate,
),
user_input,
)
class GroupConfigFlow(GroupFlow):
"""
Encapsulates all group-related steps for config & options flows.
Composition-based: call from ConfigFlow/OptionsFlow and delegate here.
Expects the parent 'flow' to expose:
- hass
- sensor_config: dict
- name: str | None
- selected_sensor_type: str | None
- async_set_unique_id(), _abort_if_unique_id_configured()
- handle_form_step(PowercalcFormStep, user_input) -> FlowResult
- async_show_menu(...), fill_schema_defaults(...),
- create_group_selector(...), create_schema_group_custom(...)
We deliberately keep this controller dumb: it only handles group UX.
"""
def __init__(self, flow: PowercalcConfigFlow) -> None:
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
async def async_step_menu_group(self, user_input: 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(
DOMAIN,
UNIQUE_ID_TRACKED_UNTRACKED,
)
if entry:
menu.remove(Step.GROUP_TRACKED_UNTRACKED)
return self.flow.async_show_menu(step_id=Step.MENU_GROUP, menu_options=menu)
async def handle_group_step(
self,
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]:
if group_type == GroupType.CUSTOM:
validate_group_input(ui)
self.flow.name = ui.get(CONF_NAME)
self.flow.sensor_config.update(ui)
self.flow.sensor_config.update({CONF_GROUP_TYPE: group_type})
return ui
self.flow.selected_sensor_type = SensorType.GROUP
step = GROUP_STEP_MAPPING[group_type]
return await self.flow.handle_form_step(
PowercalcFormStep(
step=step,
schema=schema or GROUP_SCHEMAS[group_type],
validate_user_input=_validate,
continue_utility_meter_options_step=True,
next_step=next_step,
),
user_input,
)
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> FlowResult:
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:
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:
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:
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
return await self.handle_group_step(
GroupType.TRACKED_UNTRACKED,
user_input,
schema=SCHEMA_GROUP_TRACKED_UNTRACKED,
next_step=_next,
)
async def async_step_group_tracked_untracked_auto(self, user_input: dict[str, Any] | None = None) -> FlowResult:
schema = await create_schema_tracked_untracked_auto(self.flow.hass)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GROUP_TRACKED_UNTRACKED_AUTO,
schema=schema,
continue_utility_meter_options_step=True,
),
user_input,
)
async def async_step_group_tracked_untracked_manual(self, user_input: dict[str, Any] | None = None) -> FlowResult:
schema = await create_schema_group_tracked_untracked_manual(self.flow.hass, user_input)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GROUP_TRACKED_UNTRACKED_MANUAL,
schema=schema,
continue_utility_meter_options_step=True,
),
user_input,
)
class GroupOptionsFlow(GroupFlow):
"""Handle an option flow for PowerCalc."""
def __init__(self, flow: PowercalcOptionsFlow) -> None:
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""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
)
async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""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:
"""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:
"""Handle the group options flow."""
schema = SCHEMA_GROUP_TRACKED_UNTRACKED
if self.flow.sensor_config.get(CONF_GROUP_TRACKED_AUTO, True):
schema = schema.extend((await create_schema_tracked_untracked_auto(self.flow.hass)).schema)
else:
schema = schema.extend(SCHEMA_GROUP_TRACKED_UNTRACKED_MANUAL.schema)
return await self.flow.async_handle_options_step(user_input, schema, Step.GROUP_TRACKED_UNTRACKED)
def build_group_menu(self) -> list[Step]:
"""Build the group menu."""
group_type = self.flow.sensor_config.get(CONF_GROUP_TYPE, GroupType.CUSTOM)
if group_type == GroupType.DOMAIN:
return [Step.GROUP_DOMAIN]
if group_type == GroupType.SUBTRACT:
return [Step.GROUP_SUBTRACT]
if group_type == GroupType.TRACKED_UNTRACKED:
return [Step.GROUP_TRACKED_UNTRACKED]
return [Step.GROUP_CUSTOM]
@@ -0,0 +1,554 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector, translation
import voluptuous as vol
from custom_components.powercalc import (
DOMAIN,
DeviceType,
)
from custom_components.powercalc.const import (
CONF_AVAILABILITY_ENTITY,
CONF_FIXED,
CONF_MANUFACTURER,
CONF_MODE,
CONF_MODEL,
CONF_POWER,
CONF_SELF_USAGE_INCLUDED,
CONF_SUB_PROFILE,
CONF_VARIABLES,
DUMMY_ENTITY_ID,
LIBRARY_URL,
CalculationStrategy,
)
from custom_components.powercalc.discovery import (
get_power_profile_by_source_device,
get_power_profile_by_source_entity,
)
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.power_profile.library import ModelInfo, ProfileLibrary
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
CONF_CONFIRM_AUTODISCOVERED_MODEL = "confirm_autodisovered_model"
SCHEMA_POWER_AUTODISCOVERED = vol.Schema(
{vol.Optional(CONF_CONFIRM_AUTODISCOVERED_MODEL, default=True): bool},
)
SCHEMA_POWER_OPTIONS_LIBRARY = vol.Schema(
{
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_POWER_SMART_SWITCH = vol.Schema(
{
vol.Optional(CONF_POWER): vol.Coerce(float),
vol.Optional(CONF_SELF_USAGE_INCLUDED): selector.BooleanSelector(),
},
)
class LibraryFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
async def async_step_manufacturer(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Ask the user to select the manufacturer."""
async def _create_schema() -> vol.Schema:
"""Create manufacturer schema."""
library = await ProfileLibrary.factory(self.flow.hass)
manufacturers = [
selector.SelectOptionDict(value=manufacturer[0], label=manufacturer[1])
for manufacturer in await library.get_manufacturer_listing(
self._get_library_device_types(),
self._get_library_discovery_by(),
)
]
return vol.Schema(
{
vol.Required(CONF_MANUFACTURER, default=self.flow.sensor_config.get(CONF_MANUFACTURER)): selector.SelectSelector(
selector.SelectSelectorConfig(
options=manufacturers,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)
# noinspection PyTypeChecker
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.MANUFACTURER,
schema=_create_schema,
next_step=Step.MODEL,
),
user_input,
)
async def async_step_model(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Ask the user to select the model."""
def _build_model_label(model_id: str, model_name: str) -> str:
if not model_name or model_name == model_id:
return model_id
return f"{model_id} ({model_name})"
async def _validate(user_input: dict[str, Any]) -> dict[str, str]:
library = await ProfileLibrary.factory(self.flow.hass)
profile = await library.get_profile(
ModelInfo(
str(self.flow.sensor_config.get(CONF_MANUFACTURER)),
str(user_input.get(CONF_MODEL)),
),
self.flow.source_entity,
process_variables=False,
)
self.flow.selected_profile = profile
if self.flow.selected_profile and not await self.flow.selected_profile.needs_user_configuration:
await self.flow.validate_strategy_config()
return user_input
async def _create_schema() -> vol.Schema:
"""Create model schema."""
manufacturer = str(self.flow.sensor_config.get(CONF_MANUFACTURER))
library = await ProfileLibrary.factory(self.flow.hass)
models = [
selector.SelectOptionDict(value=model_id, label=_build_model_label(model_id, model_name))
for model_id, model_name in await library.get_model_listing(
manufacturer,
self._get_library_device_types(),
self._get_library_discovery_by(),
)
]
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(
selector.SelectSelectorConfig(
options=models,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.MODEL,
schema=_create_schema,
next_step=Step.POST_LIBRARY,
validate_user_input=_validate,
form_kwarg={"description_placeholders": {"supported_models_link": LIBRARY_URL}},
),
user_input,
)
async def async_step_post_library(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""
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
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:
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:
return await self.async_step_sub_profile()
if (
Step.SMART_SWITCH not in self.flow.handled_steps
and self.flow.selected_profile.device_type == DeviceType.SMART_SWITCH
and self.flow.selected_profile.calculation_strategy == CalculationStrategy.FIXED
):
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.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
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
return await self.flow.flow_handlers[FlowType.GROUP].async_step_assign_groups() # type:ignore
async def async_step_library_custom_fields(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for custom fields."""
async def _process_user_input(user_input: dict[str, Any]) -> dict[str, Any]:
return {CONF_VARIABLES: user_input}
form_kwarg: dict[str, Any] | None = None
if self.flow.selected_profile and self.flow.selected_profile.documentation_url:
form_kwarg = {
"description_placeholders": {
"documentation_url": self.flow.selected_profile.documentation_url,
},
}
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.LIBRARY_CUSTOM_FIELDS,
schema=build_dynamic_field_schema(
self.flow.hass,
self.flow.selected_profile, # type: ignore
self.flow.source_entity,
),
next_step=Step.POST_LIBRARY,
validate_user_input=_process_user_input,
form_kwarg=form_kwarg,
),
user_input,
)
async def async_step_sub_profile(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""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]:
return {CONF_MODEL: f"{self.flow.sensor_config.get(CONF_MODEL)}/{user_input.get(CONF_SUB_PROFILE)}"}
library = await ProfileLibrary.factory(self.flow.hass)
profile = await library.get_profile(
ModelInfo(
str(self.flow.sensor_config.get(CONF_MANUFACTURER)),
str(self.flow.sensor_config.get(CONF_MODEL)),
),
self.flow.source_entity,
process_variables=False,
)
remarks = profile.config_flow_sub_profile_remarks
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
return await self.flow.handle_form_step(
PowercalcFormStep(
step=step,
schema=await build_sub_profile_schema(profile, self.flow.selected_sub_profile),
next_step=Step.POWER_ADVANCED,
validate_user_input=_validate,
form_kwarg={
"description_placeholders": {
"entity_id": self.flow.source_entity_id,
"remarks": remarks,
},
},
),
user_input,
)
async def async_step_sub_profile_per_device(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
return await self.async_step_sub_profile(user_input)
async def async_step_smart_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""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]:
return {
CONF_SELF_USAGE_INCLUDED: user_input.get(CONF_SELF_USAGE_INCLUDED),
CONF_MODE: CalculationStrategy.FIXED,
CONF_FIXED: {CONF_POWER: user_input.get(CONF_POWER, 0)},
}
self_usage_on = self.flow.selected_profile.standby_power_on if self.flow.selected_profile else 0
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.SMART_SWITCH,
schema=SCHEMA_POWER_SMART_SWITCH,
validate_user_input=_validate,
next_step=Step.POWER_ADVANCED,
form_kwarg={"description_placeholders": {"self_usage_power": str(self_usage_on)}},
),
user_input,
)
async def async_step_availability_entity(self, user_input: dict[str, Any] | None = None) -> FlowResult | None:
"""Handle the flow for availability entity."""
# Auto-resolve availability entity from profile placeholders
auto_entity = self._resolve_availability_entity()
if auto_entity:
self.flow.sensor_config[CONF_AVAILABILITY_ENTITY] = auto_entity
self.flow.handled_steps.append(Step.AVAILABILITY_ENTITY)
return None
domains = DEVICE_TYPE_DOMAIN[self.flow.selected_profile.device_type] # type: ignore
entity_selector = self.flow.create_device_entity_selector(
list(domains) if isinstance(domains, set) else [domains],
)
try:
first_entity = entity_selector.config["include_entities"][0]
except IndexError:
# Skip step if no entities are available
self.flow.handled_steps.append(Step.AVAILABILITY_ENTITY)
return None
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.AVAILABILITY_ENTITY,
schema=vol.Schema(
{
vol.Optional(CONF_AVAILABILITY_ENTITY, default=first_entity): entity_selector,
},
),
next_step=Step.POST_LIBRARY,
),
user_input,
)
def _resolve_availability_entity(self) -> str | None:
"""Try to auto-resolve an availability entity from profile placeholders."""
profile = self.flow.selected_profile
device_entry = self.flow.source_entity.device_entry if self.flow.source_entity else None
if not profile or not device_entry:
return None
for placeholder in iter_related_entity_placeholders(collect_placeholders(profile.json_data)):
entity = resolve_related_entity_placeholder(
self.flow.hass,
placeholder,
source_entity=self.flow.source_entity,
)
if entity:
return entity
return None
def _get_library_device_types(self) -> set[DeviceType] | None:
"""Determine which device types should be shown in the library selectors."""
if self._get_library_discovery_by() == DiscoveryBy.DEVICE:
return None
if self.flow.source_entity:
return DOMAIN_DEVICE_TYPE_MAPPING.get(self.flow.source_entity.domain, set())
return None # pragma: no cover
def _get_library_discovery_by(self) -> DiscoveryBy | None:
"""Determine whether listing should be filtered by discovery mode."""
if self.flow.source_entity and self.flow.source_entity.entity_id == DUMMY_ENTITY_ID:
return DiscoveryBy.DEVICE
return None
class LibraryConfigFlow(LibraryFlow):
def __init__(self, flow: PowercalcConfigFlow) -> None:
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
async def async_step_library_multi_profile(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult | 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
selected_profile = self.flow.discovered_profiles.get(selected_model)
if selected_profile is None: # pragma: no cover
return self.flow.async_abort(reason="invalid_profile")
self.flow.selected_profile = selected_profile
self.flow.sensor_config.update(
{
CONF_MANUFACTURER: selected_profile.manufacturer,
CONF_MODEL: selected_profile.model,
},
)
return await self.async_step_post_library(user_input)
schema = vol.Schema(
{
vol.Required(CONF_MODEL): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=profile.unique_id,
label=profile.model,
)
for profile in self.flow.discovered_profiles.values()
],
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)
manufacturer = str(self.flow.sensor_config.get(CONF_MANUFACTURER))
model = str(self.flow.sensor_config.get(CONF_MODEL))
return self.flow.async_show_form(
step_id=Step.LIBRARY_MULTI_PROFILE,
data_schema=schema,
description_placeholders={
"library_link": f"{LIBRARY_URL}/?manufacturer={manufacturer}",
"manufacturer": manufacturer,
"model": model,
},
last_step=False,
)
async def async_step_library(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Try to autodiscover manufacturer/model first.
Ask the user to confirm this or forward to manual library selection.
"""
if user_input is not None:
return await self._handle_library_confirmation(user_input)
await self._async_autodiscover_profile()
if not self.flow.selected_profile:
return await self.async_step_manufacturer()
return self._show_autodiscovered_profile_form()
async def _handle_library_confirmation(self, user_input: dict[str, Any]) -> FlowResult:
"""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()
self.flow.sensor_config.update(
{
CONF_MANUFACTURER: self.flow.selected_profile.manufacturer,
CONF_MODEL: self.flow.selected_profile.model,
},
)
return await self.async_step_post_library(user_input)
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:
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)
def _show_autodiscovered_profile_form(self) -> FlowResult:
"""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,
),
)
def _build_library_description_placeholders(self, profile: PowerProfile) -> dict[str, Any]:
"""Build the placeholders for the autodiscovered profile confirmation form."""
return {
"remarks": self._build_library_remarks(profile),
"manufacturer": profile.manufacturer,
"model": profile.model,
"source": self._get_profile_source(profile),
}
def _build_library_remarks(self, profile: PowerProfile) -> str | None:
"""Build the remarks text for the autodiscovered profile confirmation form."""
remarks = self._get_conditional_remarks()
if remarks:
remarks = "\n\n" + remarks
if profile.documentation_url:
return (remarks or "") + f"\n\n[Documentation]({profile.documentation_url})"
return remarks
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}"
return f"{translations.get(f'component.{DOMAIN}.common.source_entity')}: {self.flow.source_entity_id}"
def _get_conditional_remarks(self) -> str | None:
"""Get discovery remarks, only showing them if required entities are missing."""
profile = self.flow.selected_profile
if not profile:
return None # pragma: no cover
remarks = profile.config_flow_discovery_remarks
if not remarks:
return None
# Check if all entity_by_* placeholders can be resolved from the device
device_entry = self.flow.source_entity.device_entry if self.flow.source_entity else None
if not device_entry:
return remarks
related_placeholders = iter_related_entity_placeholders(collect_placeholders(profile.json_data))
all_resolved = all(
resolve_related_entity_placeholder(
self.flow.hass,
placeholder,
source_entity=self.flow.source_entity,
)
for placeholder in related_placeholders
)
return None if all_resolved else remarks
class LibraryOptionsFlow(LibraryFlow):
def __init__(self, flow: PowercalcOptionsFlow) -> None:
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def async_step_library_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the basic options flow."""
self.flow.is_library_flow = True
self.flow.selected_sub_profile = self.flow.selected_profile.sub_profile # type: ignore
if user_input is not None:
return await self.async_step_manufacturer()
return self.flow.async_show_form(
step_id=Step.LIBRARY_OPTIONS,
description_placeholders={
"manufacturer": self.flow.selected_profile.manufacturer, # type: ignore
"model": self.flow.selected_profile.model, # type: ignore
},
last_step=False,
)
@@ -0,0 +1,62 @@
"""Real-power logic for the config flow."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import SensorDeviceClass
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
from custom_components.powercalc import SensorType
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step
from custom_components.powercalc.flow_helper.schema import SCHEMA_UTILITY_METER_TOGGLE
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcConfigFlow, PowercalcOptionsFlow
SCHEMA_REAL_POWER_OPTIONS = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(device_class=SensorDeviceClass.POWER),
),
vol.Optional(CONF_DEVICE): selector.DeviceSelector(),
},
)
SCHEMA_REAL_POWER = vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
**SCHEMA_REAL_POWER_OPTIONS.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
).extend(SCHEMA_REAL_POWER_OPTIONS.schema)
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:
"""Handle the flow for real power sensor"""
self.flow.selected_sensor_type = SensorType.REAL_POWER
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.REAL_POWER,
schema=SCHEMA_REAL_POWER,
next_step=Step.ENERGY_OPTIONS,
),
user_input,
)
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:
"""Handle the real power options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_REAL_POWER_OPTIONS, Step.REAL_POWER)
@@ -0,0 +1,436 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
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
from custom_components.powercalc.common import create_source_entity
from custom_components.powercalc.const import (
CONF_AUTOSTART,
CONF_CALCULATION_ENABLED_CONDITION,
CONF_CALIBRATE,
CONF_CREATE_ENERGY_SENSOR,
CONF_CREATE_UTILITY_METERS,
CONF_GAMMA_CURVE,
CONF_IGNORE_UNAVAILABLE_STATE,
CONF_MAX_POWER,
CONF_MIN_POWER,
CONF_MODE,
CONF_MULTIPLY_FACTOR,
CONF_MULTIPLY_FACTOR_STANDBY,
CONF_PLAYBOOKS,
CONF_POWER,
CONF_POWER_OFF,
CONF_POWER_TEMPLATE,
CONF_REPEAT,
CONF_STANDBY_POWER,
CONF_STATE,
CONF_STATE_TRIGGER,
CONF_STATES_POWER,
CONF_UNAVAILABLE_POWER,
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.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.schema import (
SCHEMA_ENERGY_SENSOR_TOGGLE,
SCHEMA_SENSOR_ENERGY_OPTIONS,
SCHEMA_UTILITY_METER_TOGGLE,
)
from custom_components.powercalc.power_profile.power_profile import DeviceType
from custom_components.powercalc.strategy.wled import CONFIG_SCHEMA as SCHEMA_POWER_WLED
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
SCHEMA_POWER_ADVANCED = vol.Schema(
{
vol.Optional(CONF_CALCULATION_ENABLED_CONDITION): selector.TemplateSelector(),
vol.Optional(CONF_IGNORE_UNAVAILABLE_STATE): selector.BooleanSelector(),
vol.Optional(CONF_UNAVAILABLE_POWER): vol.Coerce(float),
vol.Optional(CONF_MULTIPLY_FACTOR): vol.Coerce(float),
vol.Optional(CONF_MULTIPLY_FACTOR_STANDBY): selector.BooleanSelector(),
},
)
SCHEMA_POWER_BASE = vol.Schema(
{
vol.Optional(CONF_NAME): selector.TextSelector(),
},
)
SCHEMA_POWER_OPTIONS = vol.Schema(
{
vol.Optional(CONF_STANDBY_POWER): vol.Coerce(float),
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
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(),
},
)
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(),
},
)
SCHEMA_POWER_MULTI_SWITCH_MANUAL = vol.Schema(
{
vol.Required(CONF_POWER): vol.Coerce(float),
vol.Required(CONF_POWER_OFF): vol.Coerce(float),
},
)
STRATEGY_SCHEMAS: dict[CalculationStrategy, vol.Schema] = {
CalculationStrategy.FIXED: SCHEMA_POWER_FIXED,
CalculationStrategy.WLED: SCHEMA_POWER_WLED,
}
STRATEGY_SELECTOR = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
CalculationStrategy.FIXED,
CalculationStrategy.LINEAR,
CalculationStrategy.MULTI_SWITCH,
CalculationStrategy.PLAYBOOK,
CalculationStrategy.WLED,
CalculationStrategy.LUT,
],
mode=selector.SelectSelectorMode.DROPDOWN,
),
)
STRATEGY_STEP_MAPPING: dict[CalculationStrategy, Step] = {
CalculationStrategy.FIXED: Step.FIXED,
CalculationStrategy.LINEAR: Step.LINEAR,
CalculationStrategy.MULTI_SWITCH: Step.MULTI_SWITCH,
CalculationStrategy.PLAYBOOK: Step.PLAYBOOK,
CalculationStrategy.WLED: Step.WLED,
}
class VirtualPowerFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
async def create_strategy_schema(self) -> vol.Schema:
"""Get the config schema for a given power calculation strategy."""
if not self.flow.strategy:
raise ValueError("No strategy selected") # pragma: no cover
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 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
{
vol.Optional(CONF_ATTRIBUTE): selector.AttributeSelector(
selector.AttributeSelectorConfig(
entity_id=self.flow.source_entity_id, # type: ignore
hide_attributes=[],
),
),
},
)
async def create_schema_multi_switch(self) -> vol.Schema:
"""Create the config schema for multi switch strategy."""
switch_domains = [str(Platform.SWITCH), str(Platform.LIGHT), str(Platform.COVER)]
if self.flow.source_entity and self.flow.source_entity.device_entry:
entity_selector = self.flow.create_device_entity_selector(switch_domains, multiple=True)
else:
entity_selector = selector.EntitySelector(
selector.EntitySelectorConfig(
domain=switch_domains,
multiple=True,
),
)
default_entities = entity_selector.config.get("include_entities", [])
schema = vol.Schema({vol.Optional(CONF_ENTITIES, default=default_entities): entity_selector})
if not self.flow.is_library_flow:
schema = schema.extend(SCHEMA_POWER_MULTI_SWITCH_MANUAL.schema)
return schema
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()]
return vol.Schema(
{
vol.Optional(CONF_PLAYBOOKS): selector.ObjectSelector(
{
"multiple": True,
"description_field": CONF_PATH,
"label_field": CONF_ID,
"fields": {
CONF_ID: {
"required": True,
"selector": {"text": None},
},
CONF_PATH: {
"required": True,
"selector": {"select": {"options": playbook_files, "mode": "dropdown", "custom_value": True}},
},
},
},
),
vol.Optional(CONF_REPEAT): selector.BooleanSelector(),
vol.Optional(CONF_AUTOSTART): selector.TextSelector(),
vol.Optional(CONF_STATE_TRIGGER): selector.ObjectSelector(),
},
)
async def handle_strategy_step(
self,
strategy: CalculationStrategy,
user_input: dict[str, Any] | None = None,
validate: Callable[[dict[str, Any]], None] | None = None,
) -> FlowResult:
self.flow.strategy = strategy
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
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/",
}
return await self.flow.handle_form_step(
PowercalcFormStep(
step=STRATEGY_STEP_MAPPING[strategy],
schema=schema,
next_step=Step.ASSIGN_GROUPS,
validate_user_input=_validate,
form_kwarg={"description_placeholders": description_placeholders},
),
user_input,
)
async def async_step_power_advanced(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for advanced options."""
if self.flow.is_options_flow:
return self.flow.persist_config_entry() # pragma: no cover
if user_input is not None or self.flow.skip_advanced_step:
self.flow.sensor_config.update(user_input or {})
if self.flow.sensor_config.get(CONF_CREATE_UTILITY_METERS):
return await self.flow.async_step_utility_meter_options()
return self.flow.persist_config_entry()
schema = SCHEMA_POWER_ADVANCED
if self.flow.sensor_config.get(CONF_CREATE_ENERGY_SENSOR):
schema = schema.extend(SCHEMA_SENSOR_ENERGY_OPTIONS.schema)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.POWER_ADVANCED,
schema=fill_schema_defaults(
schema,
get_global_powercalc_config(self.flow),
),
),
)
class VirtualPowerConfigFlow(VirtualPowerFlow):
def __init__(self, flow: PowercalcConfigFlow) -> None:
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
def create_schema_virtual_power(
self,
) -> vol.Schema:
"""Create the config schema for virtual power sensor."""
schema = vol.Schema(
{
vol.Optional(CONF_ENTITY_ID): self.flow.create_source_entity_selector(),
},
).extend(SCHEMA_POWER_BASE.schema)
if not self.flow.is_library_flow:
schema = schema.extend(
{
vol.Optional(
CONF_MODE,
default=CalculationStrategy.FIXED,
): STRATEGY_SELECTOR,
},
)
options_schema = SCHEMA_POWER_OPTIONS
else:
options_schema = SCHEMA_POWER_OPTIONS_LIBRARY
power_options = fill_schema_defaults(
options_schema,
get_global_powercalc_config(self.flow),
)
return schema.extend(power_options.schema) # type: ignore
async def async_step_virtual_power(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for virtual power sensor."""
errors: dict[str, str] = {}
if user_input is not None:
selected_strategy = CalculationStrategy(
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:
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_id,
self.flow.hass,
)
self.flow.name = user_input.get(CONF_NAME) or self.flow.source_entity.name
self.flow.selected_sensor_type = SensorType.VIRTUAL_POWER
self.flow.sensor_config.update(user_input)
return await self.forward_to_strategy_step(selected_strategy)
return self.flow.async_show_form( # type: ignore
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:
"""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:
"""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:
"""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:
"""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:
"""Handle the flow for playbook sensor."""
def _validate(user_input: dict[str, Any]) -> None:
if user_input.get(CONF_PLAYBOOKS) is None or len(user_input.get(CONF_PLAYBOOKS)) == 0: # type: ignore
raise SchemaFlowError("playbook_mandatory")
return await self.handle_strategy_step(CalculationStrategy.PLAYBOOK, user_input, _validate)
async def forward_to_strategy_step(self, strategy: CalculationStrategy) -> FlowResult:
"""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
method = getattr(self.flow, f"async_step_{step}")
return await method() # type: ignore
class VirtualPowerOptionsFlow(VirtualPowerFlow):
def __init__(self, flow: PowercalcOptionsFlow) -> None:
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def build_strategy_config(
self,
user_input: dict[str, Any],
) -> dict[str, Any]:
"""Build the config dict needed for the configured strategy."""
strategy_schema = await self.create_strategy_schema()
strategy_options: dict[str, Any] = {}
for key in strategy_schema.schema:
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()
]
return strategy_options
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""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:
"""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:
"""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:
"""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:
"""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:
"""Handle the option processing for the selected strategy."""
step = STRATEGY_STEP_MAPPING.get(self.flow.strategy or CalculationStrategy.FIXED, Step.FIXED)
schema = await self.create_strategy_schema()
if self.flow.selected_profile and self.flow.selected_profile.device_type == DeviceType.SMART_SWITCH:
schema = SCHEMA_POWER_SMART_SWITCH
strategy_options = self.flow.sensor_config.get(str(self.flow.strategy)) or {}
merged_options = {
**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]}
schema = fill_schema_defaults(schema, merged_options)
return await self.flow.async_handle_options_step(user_input, schema, step)
@@ -0,0 +1,124 @@
from homeassistant.components.utility_meter import CONF_METER_TYPE, METER_TYPES
from homeassistant.const import UnitOfPower
from homeassistant.helpers import selector
from homeassistant.helpers.selector import NumberSelector, NumberSelectorConfig, NumberSelectorMode
import voluptuous as vol
from custom_components.powercalc.const import (
CONF_CREATE_ENERGY_SENSOR,
CONF_CREATE_UTILITY_METERS,
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
CONF_ENERGY_FILTER_OUTLIER_MAX,
CONF_ENERGY_INTEGRATION_METHOD,
CONF_ENERGY_SENSOR_UNIT_PREFIX,
CONF_SUB_PROFILE,
CONF_UTILITY_METER_NET_CONSUMPTION,
CONF_UTILITY_METER_OFFSET,
CONF_UTILITY_METER_TARIFFS,
CONF_UTILITY_METER_TYPES,
ENERGY_INTEGRATION_METHOD_LEFT,
ENERGY_INTEGRATION_METHODS,
UnitPrefix,
)
from custom_components.powercalc.power_profile.power_profile import PowerProfile
SCHEMA_UTILITY_METER_TOGGLE = vol.Schema(
{
vol.Optional(CONF_CREATE_UTILITY_METERS, default=False): selector.BooleanSelector(),
},
)
SCHEMA_ENERGY_SENSOR_TOGGLE = vol.Schema(
{
vol.Optional(CONF_CREATE_ENERGY_SENSOR, default=True): selector.BooleanSelector(),
},
)
SCHEMA_ENERGY_OPTIONS = vol.Schema(
{
vol.Optional(
CONF_ENERGY_INTEGRATION_METHOD,
default=ENERGY_INTEGRATION_METHOD_LEFT,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=ENERGY_INTEGRATION_METHODS,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
vol.Optional(CONF_ENERGY_SENSOR_UNIT_PREFIX): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value=UnitPrefix.KILO, label="k (kilo)"),
selector.SelectOptionDict(value=UnitPrefix.MEGA, label="M (mega)"),
selector.SelectOptionDict(value=UnitPrefix.GIGA, label="G (giga)"),
selector.SelectOptionDict(value=UnitPrefix.TERA, label="T (tera)"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)
SCHEMA_SENSOR_ENERGY_OPTIONS = SCHEMA_ENERGY_OPTIONS.extend(
vol.Schema(
{
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_ENABLED, default=False): selector.BooleanSelector(),
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_MAX): NumberSelector(
NumberSelectorConfig(mode=NumberSelectorMode.BOX, unit_of_measurement=UnitOfPower.WATT),
),
},
).schema,
)
SCHEMA_UTILITY_METER_OPTIONS = vol.Schema(
{
vol.Required(CONF_UTILITY_METER_TYPES): selector.SelectSelector(
selector.SelectSelectorConfig(
options=METER_TYPES,
translation_key=CONF_METER_TYPE,
multiple=True,
),
),
vol.Optional(CONF_UTILITY_METER_TARIFFS, default=[]): selector.SelectSelector(
selector.SelectSelectorConfig(options=[], custom_value=True, multiple=True),
),
vol.Optional(CONF_UTILITY_METER_NET_CONSUMPTION, default=False): selector.BooleanSelector(),
vol.Required(CONF_UTILITY_METER_OFFSET, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=28,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="days",
),
),
},
)
async def build_sub_profile_schema(
profile: PowerProfile,
selected_sub_profile: str | None,
) -> vol.Schema:
"""Create sub profile schema."""
sub_profiles = [
selector.SelectOptionDict(
value=sub_profile[0],
label=sub_profile[1]["name"] if "name" in sub_profile[1] else sub_profile[0],
)
for sub_profile in await profile.get_sub_profiles()
]
return vol.Schema(
{
vol.Required(
CONF_SUB_PROFILE,
description={"suggested_value": selected_sub_profile},
default=selected_sub_profile,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=sub_profiles,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)