updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from homeassistant.data_entry_flow import section
import voluptuous as vol
@@ -32,6 +33,7 @@ class Step(StrEnum):
POWER_ADVANCED = "power_advanced"
DAILY_ENERGY = "daily_energy"
REAL_POWER = "real_power"
COST = "cost"
MANUFACTURER = "manufacturer"
MENU_LIBRARY = "menu_library"
MENU_GROUP = "menu_group"
@@ -46,6 +48,8 @@ class Step(StrEnum):
GLOBAL_CONFIGURATION = "global_configuration"
GLOBAL_CONFIGURATION_DISCOVERY = "global_configuration_discovery"
GLOBAL_CONFIGURATION_ENERGY = "global_configuration_energy"
GLOBAL_CONFIGURATION_COST = "global_configuration_cost"
GLOBAL_CONFIGURATION_COST_APPLY = "global_configuration_cost_apply"
GLOBAL_CONFIGURATION_THROTTLING = "global_configuration_throttling"
GLOBAL_CONFIGURATION_UTILITY_METER = "global_configuration_utility_meter"
@@ -54,6 +58,7 @@ class FlowType(StrEnum):
VIRTUAL_POWER = "virtual_power"
DAILY_ENERGY = "daily_energy"
REAL_POWER = "real_power"
COST = "cost"
LIBRARY = "library"
GROUP = "group"
GLOBAL_CONFIGURATION = "global_configuration"
@@ -89,7 +94,10 @@ def fill_schema_defaults(
schema = {}
for key, val in data_schema.schema.items():
new_key = key
if key in options and isinstance(key, vol.Marker):
if isinstance(val, section):
# Recurse into collapsible sections, filling their fields from the flat options.
val = section(fill_schema_defaults(val.schema, options), val.options)
elif 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[call-overload]
elif isinstance(key, vol.Required):
@@ -102,6 +110,29 @@ def fill_schema_defaults(
return vol.Schema(schema)
def flatten_sections(user_input: dict[str, Any], schema: vol.Schema) -> dict[str, Any]:
"""Flatten values nested under `section` wrappers back into a flat dict.
Fields presented inside collapsible sections are returned by Home Assistant as a nested
dict keyed by the section name. This merges them back to the top level so the rest of the
flow can keep treating the user input as flat.
"""
if not user_input:
return user_input
section_keys = {
(key.schema if isinstance(key, vol.Marker) else key)
for key, val in schema.schema.items()
if isinstance(val, section)
}
flat: dict[str, Any] = {}
for key, value in user_input.items():
if key in section_keys and isinstance(value, dict):
flat.update(value)
else:
flat[key] = value
return flat
def unwrap_choose_selector(
user_input: dict[str, Any],
wrapper_key: str,
@@ -23,12 +23,21 @@ def build_dynamic_field_schema(
else:
key = vol.Required(field.key, description=field_description)
field_selector = field.selector
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)
]
# Build a new selector dict instead of mutating field.selector, which is a reference
# into the (potentially cached) profile json_data.
field_selector = {
**field.selector,
"entity": {
**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)
schema[key] = selector(field_selector)
return vol.Schema(schema)
@@ -0,0 +1,75 @@
"""Config/options flow for a standalone cost sensor based on an existing energy sensor."""
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_NAME
from homeassistant.helpers import selector
import voluptuous as vol
from custom_components.powercalc.const import (
CONF_ENERGY_PRICE,
CONF_ENERGY_PRICE_SENSOR,
CONF_ENERGY_SENSOR_ID,
DOMAIN,
DOMAIN_CONFIG,
SensorType,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from custom_components.powercalc.config_flow import PowercalcConfigFlow, PowercalcOptionsFlow
SCHEMA_COST_OPTIONS = vol.Schema(
{
vol.Required(CONF_ENERGY_SENSOR_ID): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=SensorDeviceClass.ENERGY),
),
},
)
SCHEMA_COST = vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
**SCHEMA_COST_OPTIONS.schema,
},
)
def is_global_price_configured(hass: HomeAssistant) -> bool:
"""Check whether a global energy price (fixed or sensor) has been configured."""
global_config = hass.data.get(DOMAIN, {}).get(DOMAIN_CONFIG, {})
return bool(global_config.get(CONF_ENERGY_PRICE) or global_config.get(CONF_ENERGY_PRICE_SENSOR))
class CostConfigFlow:
def __init__(self, flow: PowercalcConfigFlow) -> None:
self.flow: PowercalcConfigFlow = flow
async def async_step_cost(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the flow for a standalone cost sensor."""
if not is_global_price_configured(self.flow.hass):
return self.flow.async_abort(
reason="cost_no_global_price",
description_placeholders={"url": "https://docs.powercalc.nl/sensor-types/cost-sensor/"},
)
self.flow.selected_sensor_type = SensorType.COST
return await self.flow.handle_form_step(
PowercalcFormStep(step=Step.COST, schema=SCHEMA_COST),
user_input,
)
class CostOptionsFlow:
def __init__(self, flow: PowercalcOptionsFlow) -> None:
self.flow: PowercalcOptionsFlow = flow
async def async_step_cost(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the cost sensor options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_COST_OPTIONS, Step.COST)
@@ -5,12 +5,17 @@ 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 section
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from custom_components.powercalc import DeviceType
from custom_components.powercalc.const import (
CONF_APPLY_TO_ALL,
CONF_CREATE_COST_SENSOR,
CONF_CREATE_COST_SENSORS,
CONF_CREATE_ENERGY_SENSORS,
CONF_CREATE_STANDBY_GROUP,
CONF_CREATE_UTILITY_METERS,
@@ -18,6 +23,8 @@ from custom_components.powercalc.const import (
CONF_DISABLE_LIBRARY_DOWNLOAD,
CONF_DISCOVERY,
CONF_ENABLE_ANALYTICS,
CONF_ENERGY_PRICE,
CONF_ENERGY_PRICE_SENSOR,
CONF_ENERGY_SENSOR_CATEGORY,
CONF_ENERGY_SENSOR_FRIENDLY_NAMING,
CONF_ENERGY_SENSOR_NAMING,
@@ -44,17 +51,27 @@ from custom_components.powercalc.const import (
ENTITY_CATEGORIES,
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, flatten_sections
from custom_components.powercalc.flow_helper.schema import (
SCHEMA_COST_APPLY,
SCHEMA_ENERGY_OPTIONS,
SCHEMA_GLOBAL_COST,
SCHEMA_GLOBAL_COST_FLAT,
SCHEMA_UTILITY_METER_OPTIONS,
SCHEMA_UTILITY_METER_TOGGLE,
SECTION_COST_NAMING,
SECTION_COST_PRICING,
)
from custom_components.powercalc.service.gui_configuration import apply_field_to_config_entries
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
SCHEMA_GLOBAL_CONFIGURATION = vol.Schema(
SECTION_GLOBAL_POWER = "power_options"
SECTION_GLOBAL_FEATURES = "features"
SECTION_GLOBAL_ADVANCED = "advanced"
SCHEMA_GLOBAL_CONFIGURATION_POWER = vol.Schema(
{
vol.Optional(CONF_POWER_SENSOR_NAMING): selector.TextSelector(),
vol.Optional(CONF_POWER_SENSOR_FRIENDLY_NAMING): selector.TextSelector(),
@@ -67,20 +84,41 @@ SCHEMA_GLOBAL_CONFIGURATION = vol.Schema(
vol.Optional(CONF_POWER_SENSOR_PRECISION): selector.NumberSelector(
selector.NumberSelectorConfig(min=0, max=6, mode=selector.NumberSelectorMode.BOX, step=1),
),
},
)
SCHEMA_GLOBAL_CONFIGURATION_FEATURES = vol.Schema(
{
vol.Optional(CONF_CREATE_ENERGY_SENSORS, default=True): selector.BooleanSelector(),
vol.Optional(CONF_CREATE_COST_SENSORS, default=False): selector.BooleanSelector(),
vol.Optional(CONF_CREATE_STANDBY_GROUP, default=True): selector.BooleanSelector(),
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_GLOBAL_CONFIGURATION_ADVANCED = vol.Schema(
{
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,
},
)
# Presented in the GUI as three collapsible sections (power sensor, features, advanced).
SCHEMA_GLOBAL_CONFIGURATION = vol.Schema(
{
vol.Required(SECTION_GLOBAL_POWER): section(SCHEMA_GLOBAL_CONFIGURATION_POWER),
vol.Required(SECTION_GLOBAL_FEATURES): section(SCHEMA_GLOBAL_CONFIGURATION_FEATURES),
vol.Required(SECTION_GLOBAL_ADVANCED): section(SCHEMA_GLOBAL_CONFIGURATION_ADVANCED, {"collapsed": True}),
},
)
SCHEMA_GLOBAL_CONFIGURATION_DISCOVERY = vol.Schema(
{
vol.Optional(CONF_ENABLED, default=True): selector.BooleanSelector(),
vol.Optional(CONF_EXCLUDE_SELF_USAGE, default=False): selector.BooleanSelector(),
vol.Optional(CONF_EXCLUDE_DEVICE_TYPES): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[cls.value for cls in DeviceType],
@@ -88,7 +126,6 @@ SCHEMA_GLOBAL_CONFIGURATION_DISCOVERY = vol.Schema(
multiple=True,
),
),
vol.Optional(CONF_EXCLUDE_SELF_USAGE, default=False): selector.BooleanSelector(),
},
)
@@ -139,13 +176,16 @@ def merge_global_config(global_config: ConfigType, user_input: dict[str, Any], s
Keys present in the schema but absent from the user input were cleared in the form
and must be removed, otherwise a previously saved value would incorrectly persist.
"""
for key in schema.schema:
if isinstance(key, vol.Marker):
key = key.schema
if key in user_input:
global_config[key] = user_input[key]
elif key in global_config:
global_config.pop(key)
for key, val in schema.schema.items():
base_key = key.schema if isinstance(key, vol.Marker) else key
if isinstance(val, section):
# Recurse into collapsible sections, whose values are nested under the section key.
merge_global_config(global_config, user_input.get(base_key) or {}, val.schema)
continue
if base_key in user_input:
global_config[base_key] = user_input[base_key]
elif base_key in global_config:
global_config.pop(base_key)
def get_global_powercalc_config(flow: PowercalcCommonFlow) -> ConfigType:
@@ -167,6 +207,11 @@ class GlobalConfigurationFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
def is_energy_price_configured(self) -> bool:
"""Check whether an energy price (fixed or sensor) has been configured globally."""
config = self.flow.global_config
return bool(config.get(CONF_ENERGY_PRICE) or config.get(CONF_ENERGY_PRICE_SENSOR))
async def async_step_global_configuration_discovery(
self,
user_input: dict[str, Any] | None = None,
@@ -257,10 +302,7 @@ class GlobalConfigurationFlow:
self.flow.global_config.update(user_input)
if not bool(self.flow.global_config.get(CONF_CREATE_UTILITY_METERS)) or user_input is not None:
return self.flow.async_create_entry(
title="Global Configuration",
data=self.flow.global_config,
)
return await self.async_step_global_configuration_cost()
return await self.flow.handle_form_step(
PowercalcFormStep(
@@ -274,6 +316,65 @@ class GlobalConfigurationFlow:
),
)
async def async_step_global_configuration_cost(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle the global cost sensor configuration step (energy price)."""
form_step = PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_COST,
schema=SCHEMA_GLOBAL_COST,
form_kwarg={
"description_placeholders": {
"docs_uri": "https://docs.powercalc.nl/sensor-types/cost-sensor/",
},
},
)
if user_input is not None:
# The form presents pricing and naming as two sections, flatten them back to plain keys.
user_input = {**user_input.get(SECTION_COST_PRICING, {}), **user_input.get(SECTION_COST_NAMING, {})}
if not user_input.get(CONF_ENERGY_PRICE) and not user_input.get(CONF_ENERGY_PRICE_SENSOR):
return await self.flow._show_form(form_step, SchemaFlowError("cost_price_mandatory")) # noqa: SLF001
if self.flow.is_options_flow:
merge_global_config(self.flow.global_config, user_input, SCHEMA_GLOBAL_COST_FLAT)
return self.flow.persist_config_entry()
self.flow.global_config.update(user_input)
if not bool(self.flow.global_config.get(CONF_CREATE_COST_SENSORS)) or user_input is not None:
return self.flow.async_create_entry(
title="Global Configuration",
data=self.flow.global_config,
)
return await self.flow.handle_form_step(form_step)
async def async_step_global_configuration_cost_apply(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Ask whether to apply the changed create_cost_sensors setting to existing GUI sensors."""
if user_input is not None:
if user_input.get(CONF_APPLY_TO_ALL):
apply_field_to_config_entries(
self.flow.hass,
CONF_CREATE_COST_SENSOR,
bool(self.flow.global_config.get(CONF_CREATE_COST_SENSORS)),
)
# When cost sensors were just enabled but no price is configured yet, continue to the price step.
if self.flow.global_config.get(CONF_CREATE_COST_SENSORS) and not self.is_energy_price_configured():
return await self.async_step_global_configuration_cost()
return self.flow.persist_config_entry()
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_COST_APPLY,
schema=SCHEMA_COST_APPLY,
),
)
class GlobalConfigurationConfigFlow(GlobalConfigurationFlow):
def __init__(self, flow: PowercalcConfigFlow) -> None:
@@ -287,7 +388,7 @@ class GlobalConfigurationConfigFlow(GlobalConfigurationFlow):
self.flow.abort_if_unique_id_configured()
if user_input is not None:
self.flow.global_config.update(user_input)
self.flow.global_config.update(flatten_sections(user_input, SCHEMA_GLOBAL_CONFIGURATION))
return await self.async_step_global_configuration_discovery()
return await self.flow.handle_form_step(
@@ -317,6 +418,8 @@ class GlobalConfigurationOptionsFlow(GlobalConfigurationFlow):
}
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_COST_SENSORS):
menu[Step.GLOBAL_CONFIGURATION_COST] = "Cost options"
if self.flow.global_config.get(CONF_CREATE_UTILITY_METERS):
menu[Step.GLOBAL_CONFIGURATION_UTILITY_METER] = "Utility meter options"
return menu
@@ -325,7 +428,12 @@ class GlobalConfigurationOptionsFlow(GlobalConfigurationFlow):
"""Handle the global configuration step."""
if user_input is not None:
cost_sensors_before = bool(self.flow.global_config.get(CONF_CREATE_COST_SENSORS))
merge_global_config(self.flow.global_config, user_input, SCHEMA_GLOBAL_CONFIGURATION)
# When the create_cost_sensors toggle is flipped (either direction), offer to apply
# the change to all existing GUI sensors in a dedicated step.
if bool(self.flow.global_config.get(CONF_CREATE_COST_SENSORS)) != cost_sensors_before:
return await self.async_step_global_configuration_cost_apply()
return self.flow.persist_config_entry()
return await self.flow.handle_form_step(
@@ -15,6 +15,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import section
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
from homeassistant.helpers.selector import TextSelector
@@ -45,7 +46,12 @@ from custom_components.powercalc.const import (
GroupType,
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,
flatten_sections,
)
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
@@ -58,6 +64,9 @@ if TYPE_CHECKING:
# Constants
UNIQUE_ID_TRACKED_UNTRACKED = "pc_tracked_untracked"
SECTION_GROUP_MEMBERS = "members"
SECTION_GROUP_OPTIONS = "options"
# Schemas
SCHEMA_GROUP = vol.Schema(
{
@@ -192,7 +201,10 @@ def create_schema_group_custom(
config_entry: ConfigEntry | None = None,
is_option_flow: bool = False,
) -> vol.Schema:
"""Create config schema for groups."""
"""Create config schema for groups.
Presented in the GUI as two collapsible sections (members and options).
"""
member_sensors = [
selector.SelectOptionDict(value=config_entry.entry_id, label=config_entry.title)
for config_entry in hass.config_entries.async_entries(DOMAIN)
@@ -208,7 +220,7 @@ def create_schema_group_custom(
),
)
schema = vol.Schema(
members_schema = vol.Schema(
{
vol.Optional(CONF_GROUP_MEMBER_SENSORS): member_sensor_selector,
vol.Optional(CONF_GROUP_MEMBER_DEVICES): selector.DeviceSelector(
@@ -236,6 +248,11 @@ def create_schema_group_custom(
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(),
},
)
options_schema = vol.Schema(
{
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(),
@@ -244,7 +261,7 @@ def create_schema_group_custom(
)
if not is_option_flow:
schema = schema.extend(
options_schema = options_schema.extend(
{
vol.Optional(CONF_GROUP_ENERGY_START_AT_ZERO, default=True): selector.BooleanSelector(),
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
@@ -252,7 +269,12 @@ def create_schema_group_custom(
},
)
return schema
return vol.Schema(
{
vol.Required(SECTION_GROUP_MEMBERS): section(members_schema),
vol.Required(SECTION_GROUP_OPTIONS): section(options_schema),
},
)
def create_group_selector(
@@ -388,7 +410,11 @@ class GroupConfigFlow(GroupFlow):
schema: vol.Schema | None = None,
next_step: Callable[[dict[str, Any]], Step | None] | None = None,
) -> ConfigFlowResult:
resolved_schema = schema or GROUP_SCHEMAS[group_type]
def _validate(ui: dict[str, Any]) -> dict[str, Any]:
# Flatten collapsible sections (e.g. the custom group members/options) back to flat keys.
ui = flatten_sections(ui, resolved_schema)
if group_type == GroupType.CUSTOM:
validate_group_input(ui)
@@ -403,7 +429,7 @@ class GroupConfigFlow(GroupFlow):
return await self.flow.handle_form_step(
PowercalcFormStep(
step=step,
schema=schema or GROUP_SCHEMAS[group_type],
schema=resolved_schema,
validate_user_input=_validate,
continue_utility_meter_options_step=True,
next_step=next_step,
@@ -412,7 +438,8 @@ class GroupConfigFlow(GroupFlow):
)
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)
# Keep the name at top level; the remaining fields are grouped into collapsible sections.
schema = vol.Schema({vol.Required(CONF_NAME): str}).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) -> ConfigFlowResult:
@@ -47,8 +47,6 @@ 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.flows.global_configuration import get_global_powercalc_config
from custom_components.powercalc.flow_helper.flows.library import (
@@ -61,6 +59,13 @@ from custom_components.powercalc.flow_helper.schema import (
SCHEMA_SENSOR_ENERGY_OPTIONS,
SCHEMA_UTILITY_METER_TOGGLE,
)
from custom_components.powercalc.flow_helper.strategy_form import (
FIXED_CHOICES,
find_present_choice,
order_choices_for_default,
unwrap_strategy_user_input,
wrap_strategy_form_data,
)
from custom_components.powercalc.power_profile.power_profile import DeviceType
from custom_components.powercalc.strategy.wled import CONFIG_SCHEMA as SCHEMA_POWER_WLED
@@ -110,28 +115,6 @@ FIXED_CHOICE_SELECTORS: dict[str, selector.ChooseSelectorChoiceConfig] = {
}
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.Required(CONF_FIXED_VALUE): selector.ChooseSelector(
@@ -168,48 +151,6 @@ SCHEMA_POWER_LINEAR = vol.Schema(
},
)
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),
@@ -13,9 +13,9 @@ 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.const import CalculationStrategy
from custom_components.powercalc.errors import StrategyConfigurationError, UnsupportedStrategyError
from custom_components.powercalc.flow_helper.common import unwrap_choose_selector
from custom_components.powercalc.flow_helper.strategy_form import unwrap_strategy_user_input
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
@@ -125,18 +125,10 @@ def _build_preview_sensor_config(flow: PreviewFlowProtocol, step_id: str, user_i
except ValueError:
return sensor_config
sensor_config[strategy] = _unwrap_preview_strategy_input(strategy, user_input)
sensor_config[strategy] = unwrap_strategy_user_input(strategy, dict(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,
@@ -177,7 +169,7 @@ async def _calculate_current_power(
power_profile,
source_entity,
)
except (StrategyConfigurationError, UnsupportedStrategyError):
except StrategyConfigurationError, UnsupportedStrategyError:
return None
try:
@@ -1,15 +1,25 @@
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.utility_meter import CONF_METER_TYPE, METER_TYPES
from homeassistant.const import UnitOfPower
from homeassistant.data_entry_flow import section
from homeassistant.helpers import selector
from homeassistant.helpers.selector import NumberSelector, NumberSelectorConfig, NumberSelectorMode
from homeassistant.helpers.selector import NumberSelector, NumberSelectorMode
import voluptuous as vol
from custom_components.powercalc.const import (
CONF_APPLY_TO_ALL,
CONF_COST_SENSOR_FRIENDLY_NAMING,
CONF_COST_SENSOR_NAMING,
CONF_CREATE_COST_SENSOR,
CONF_CREATE_ENERGY_SENSOR,
CONF_CREATE_UTILITY_METERS,
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
CONF_ENERGY_FILTER_OUTLIER_MAX,
CONF_ENERGY_INTEGRATION_METHOD,
CONF_ENERGY_PRICE,
CONF_ENERGY_PRICE_MULTIPLIER,
CONF_ENERGY_PRICE_SENSOR,
CONF_ENERGY_PRICE_SURCHARGE,
CONF_ENERGY_SENSOR_UNIT_PREFIX,
CONF_SUB_PROFILE,
CONF_UTILITY_METER_NET_CONSUMPTION,
@@ -34,6 +44,58 @@ SCHEMA_ENERGY_SENSOR_TOGGLE = vol.Schema(
},
)
SCHEMA_COST_SENSOR_TOGGLE = vol.Schema(
{
vol.Optional(CONF_CREATE_COST_SENSOR, default=False): selector.BooleanSelector(),
},
)
SECTION_COST_PRICING = "cost_pricing"
SECTION_COST_NAMING = "cost_naming"
SCHEMA_GLOBAL_COST_PRICING = vol.Schema(
{
vol.Optional(CONF_ENERGY_PRICE): NumberSelector(
selector.NumberSelectorConfig(mode=NumberSelectorMode.BOX, step="any"),
),
vol.Optional(CONF_ENERGY_PRICE_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=SensorDeviceClass.MONETARY),
),
vol.Optional(CONF_ENERGY_PRICE_SURCHARGE): NumberSelector(
selector.NumberSelectorConfig(mode=NumberSelectorMode.BOX, step="any"),
),
vol.Optional(CONF_ENERGY_PRICE_MULTIPLIER): NumberSelector(
selector.NumberSelectorConfig(mode=NumberSelectorMode.BOX, step="any"),
),
},
)
SCHEMA_GLOBAL_COST_NAMING = vol.Schema(
{
vol.Optional(CONF_COST_SENSOR_NAMING): selector.TextSelector(),
vol.Optional(CONF_COST_SENSOR_FRIENDLY_NAMING): selector.TextSelector(),
},
)
# Presented in the GUI as two collapsible sections (pricing and naming).
SCHEMA_GLOBAL_COST = vol.Schema(
{
vol.Required(SECTION_COST_PRICING): section(SCHEMA_GLOBAL_COST_PRICING),
vol.Required(SECTION_COST_NAMING): section(SCHEMA_GLOBAL_COST_NAMING, {"collapsed": True}),
},
)
# Flat variant with all cost keys, used to merge/clear the (un)nested user input.
SCHEMA_GLOBAL_COST_FLAT = SCHEMA_GLOBAL_COST_PRICING.extend(SCHEMA_GLOBAL_COST_NAMING.schema)
# Shown when the global create_cost_sensors toggle is flipped, to optionally propagate the
# change to all existing GUI sensors.
SCHEMA_COST_APPLY = vol.Schema(
{
vol.Optional(CONF_APPLY_TO_ALL, default=True): selector.BooleanSelector(),
},
)
SCHEMA_ENERGY_OPTIONS = vol.Schema(
{
vol.Optional(
@@ -64,7 +126,7 @@ SCHEMA_SENSOR_ENERGY_OPTIONS = SCHEMA_ENERGY_OPTIONS.extend(
{
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),
selector.NumberSelectorConfig(mode=NumberSelectorMode.BOX, unit_of_measurement=UnitOfPower.WATT),
),
},
).schema,
@@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Any
from custom_components.powercalc.const import (
CONF_FIXED_VALUE,
CONF_PLAYBOOK_ID,
CONF_POWER,
CONF_POWER_TEMPLATE,
CONF_STATE,
CONF_STATE_TRIGGER,
CONF_STATES_POWER,
CalculationStrategy,
)
from custom_components.powercalc.flow_helper.common import unwrap_choose_selector, wrap_choose_selector
FIXED_CHOICES: dict[str, list[str] | str] = {
CONF_STATES_POWER: CONF_STATES_POWER,
CONF_POWER_TEMPLATE: CONF_POWER_TEMPLATE,
CONF_POWER: CONF_POWER,
}
def order_choices_for_default[T](
choices: dict[str, T],
default_choice: str | None,
) -> dict[str, T]:
"""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 has_saved_choice_value(value: object) -> bool:
"""Return whether a saved strategy value should drive the selected form choice."""
if value is None:
return False
if isinstance(value, (str, list, dict)):
return bool(value)
return True
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(has_saved_choice_value(form_data.get(key)) for key in keys):
return choice_id
return None
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 form-only selector wrappers and normalize 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 stored strategy config back into form-only selector structures."""
if strategy == CalculationStrategy.FIXED:
choices = order_choices_for_default(FIXED_CHOICES, find_present_choice(form_data, FIXED_CHOICES))
form_data = wrap_choose_selector(form_data, CONF_FIXED_VALUE, 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