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
@@ -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),