329 files

This commit is contained in:
Home Assistant Version Control
2026-08-06 13:56:25 +00:00
parent 0df89406fa
commit 7afe7add1d
330 changed files with 13098 additions and 5942 deletions
@@ -39,6 +39,7 @@ class Step(StrEnum):
MENU_LIBRARY = "menu_library"
MENU_GROUP = "menu_group"
MODEL = "model"
SELECT_DEVICE = "select_device"
SUB_PROFILE = "sub_profile"
SUB_PROFILE_PER_DEVICE = "sub_profile_per_device"
USER = "user"
@@ -1,12 +1,11 @@
"""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.core import HomeAssistant
from homeassistant.data_entry_flow import section
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
@@ -21,28 +20,35 @@ from custom_components.powercalc.const import (
SensorType,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, flatten_sections
from custom_components.powercalc.flow_helper.schema import SCHEMA_COST_PRICING, SECTION_COST_PRICING
from custom_components.powercalc.flow_helper.schema import SECTION_COST_PRICING, build_cost_pricing_schema
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),
),
vol.Optional(SECTION_COST_PRICING): section(SCHEMA_COST_PRICING, {"collapsed": True}),
},
)
SCHEMA_COST = vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
**SCHEMA_COST_OPTIONS.schema,
},
)
def build_cost_options_schema(hass: HomeAssistant, current_price_entity_id: str | None = None) -> vol.Schema:
"""Return the schema for the options of a standalone cost sensor."""
return vol.Schema(
{
vol.Required(CONF_ENERGY_SENSOR_ID): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=SensorDeviceClass.ENERGY),
),
vol.Optional(SECTION_COST_PRICING): section(
build_cost_pricing_schema(hass, current_price_entity_id),
{"collapsed": True},
),
},
)
def build_cost_schema(hass: HomeAssistant) -> vol.Schema:
"""Return the schema for creating a standalone cost sensor."""
return vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
**build_cost_options_schema(hass).schema,
},
)
def is_global_price_configured(hass: HomeAssistant) -> bool:
@@ -68,13 +74,17 @@ class CostConfigFlow:
"""Handle the flow for a standalone cost sensor."""
self.flow.selected_sensor_type = SensorType.COST
return await self.flow.handle_form_step(
PowercalcFormStep(step=Step.COST, schema=SCHEMA_COST, validate_user_input=self.validate),
PowercalcFormStep(
step=Step.COST,
schema=build_cost_schema(self.flow.hass),
validate_user_input=self.validate,
),
user_input,
)
def validate(self, user_input: dict[str, Any]) -> dict[str, Any]:
"""Flatten the pricing section and reject the input when no price is available."""
user_input = flatten_sections(user_input, SCHEMA_COST)
user_input = flatten_sections(user_input, build_cost_schema(self.flow.hass))
if validate_price_configured(self.flow.hass, user_input):
raise SchemaFlowError("cost_price_mandatory")
return user_input
@@ -88,7 +98,7 @@ class CostOptionsFlow:
"""Handle the cost sensor options flow."""
return await self.flow.async_handle_options_step(
user_input,
SCHEMA_COST_OPTIONS,
build_cost_options_schema(self.flow.hass, self.flow.sensor_config.get(CONF_ENERGY_PRICE_SENSOR)),
Step.COST,
validate=lambda flat_input: validate_price_configured(self.flow.hass, flat_input),
)
@@ -1,5 +1,3 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigFlowResult
@@ -1,5 +1,3 @@
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING, Any
@@ -55,12 +53,12 @@ from custom_components.powercalc.flow_helper.schema import (
COST_DOCS_URI,
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,
build_global_cost_schema,
)
from custom_components.powercalc.power_profile.power_profile import DeviceType
from custom_components.powercalc.service.gui_configuration import apply_field_to_config_entries
@@ -325,7 +323,7 @@ class GlobalConfigurationFlow:
form_step = PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_COST,
schema=SCHEMA_GLOBAL_COST,
schema=build_global_cost_schema(self.flow.hass, self.flow.global_config.get(CONF_ENERGY_PRICE_SENSOR)),
form_kwarg={
"description_placeholders": {
"docs_uri": COST_DOCS_URI,
@@ -1,7 +1,5 @@
"""Group-related logic for the config flow."""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
@@ -1,8 +1,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_DEVICE
from homeassistant.helpers import selector, translation
import voluptuous as vol
@@ -21,6 +20,7 @@ from custom_components.powercalc.const import (
LIBRARY_URL,
CalculationStrategy,
)
from custom_components.powercalc.device_binding import get_devices_for_config_entry
from custom_components.powercalc.discovery import (
get_power_profile_by_source_device,
get_power_profile_by_source_entity,
@@ -29,6 +29,7 @@ from custom_components.powercalc.flow_helper.common import FlowType, PowercalcFo
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_STANDBY_ENERGY_SENSOR_TOGGLE,
SCHEMA_UTILITY_METER_TOGGLE,
build_sub_profile_schema,
)
@@ -58,6 +59,7 @@ SCHEMA_POWER_AUTODISCOVERED = vol.Schema(
SCHEMA_POWER_OPTIONS_LIBRARY = vol.Schema(
{
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
@@ -208,6 +210,9 @@ class LibraryFlow:
if Step.LIBRARY_CUSTOM_FIELDS not in handled_steps and profile.has_custom_fields:
return await self.async_step_library_custom_fields()
if Step.SELECT_DEVICE not in handled_steps and profile.discovery_by == DiscoveryBy.CONFIG_ENTRY:
return await self.async_step_select_device()
if Step.AVAILABILITY_ENTITY not in handled_steps and profile.discovery_by == DiscoveryBy.DEVICE:
result = await self.async_step_availability_entity()
if result:
@@ -218,6 +223,35 @@ class LibraryFlow:
return None
async def async_step_select_device(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Ask which device should receive the entities created for a config-entry profile."""
assert self.flow.source_entity is not None
assert self.flow.source_entity.config_entry_id is not None
devices = get_devices_for_config_entry(self.flow.hass, self.flow.source_entity.config_entry_id)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.SELECT_DEVICE,
schema=vol.Schema(
{
vol.Required(CONF_DEVICE): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=device.id,
label=device.name_by_user or device.name or device.id,
)
for device in devices
],
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
),
next_step=Step.POST_LIBRARY,
),
user_input,
)
async def _async_next_strategy_step(self, profile: PowerProfile) -> ConfigFlowResult | None:
"""Return the next step needed to configure the calculation strategy, or None when nothing is left to ask."""
handled_steps = self.flow.handled_steps
@@ -397,7 +431,7 @@ class LibraryFlow:
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:
if self._get_library_discovery_by() in (DiscoveryBy.CONFIG_ENTRY, DiscoveryBy.DEVICE):
return None
if self.flow.source_entity:
@@ -407,9 +441,19 @@ class LibraryFlow:
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
source_entity = self.flow.source_entity
if not source_entity:
return None
if source_entity.config_entry_id:
return DiscoveryBy.CONFIG_ENTRY
if source_entity.entity_id != DUMMY_ENTITY_ID:
return None
profile = self.flow.selected_profile
if profile and profile.discovery_by == DiscoveryBy.CONFIG_ENTRY:
return DiscoveryBy.CONFIG_ENTRY
return DiscoveryBy.DEVICE
class LibraryConfigFlow(LibraryFlow):
@@ -546,19 +590,22 @@ class LibraryConfigFlow(LibraryFlow):
def _get_profile_source(self, profile: PowerProfile) -> str:
"""Build the autodiscovery source description."""
source_entity = self.flow.source_entity
if source_entity and profile.discovery_by == DiscoveryBy.CONFIG_ENTRY:
config_entry_id = source_entity.config_entry_id
if config_entry_id:
return source_entity.name or config_entry_id
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
):
device_entry = source_entity.device_entry if source_entity else None
if profile.discovery_by == DiscoveryBy.DEVICE and device_entry:
label = translations.get(f"component.{DOMAIN}.common.source_device")
return f"{label}: {self.flow.source_entity.device_entry.name}"
return f"{label}: {device_entry.name}"
return f"{translations.get(f'component.{DOMAIN}.common.source_entity')}: {self.flow.source_entity_id}"
@@ -1,7 +1,5 @@
"""Real-power logic for the config flow."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import SensorDeviceClass
@@ -1,5 +1,3 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -16,6 +14,7 @@ from custom_components.powercalc.const import (
CONF_CALCULATION_ENABLED_CONDITION,
CONF_CALIBRATE,
CONF_CREATE_ENERGY_SENSOR,
CONF_CREATE_STANDBY_ENERGY_SENSOR,
CONF_CREATE_UTILITY_METERS,
CONF_FIXED,
CONF_FIXED_VALUE,
@@ -57,6 +56,7 @@ from custom_components.powercalc.flow_helper.profile_preview import PREVIEW_NAME
from custom_components.powercalc.flow_helper.schema import (
SCHEMA_ENERGY_SENSOR_TOGGLE,
SCHEMA_SENSOR_ENERGY_OPTIONS,
SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE,
SCHEMA_UTILITY_METER_TOGGLE,
)
from custom_components.powercalc.flow_helper.strategy_form import (
@@ -92,6 +92,7 @@ SCHEMA_POWER_OPTIONS = vol.Schema(
{
vol.Optional(CONF_STANDBY_POWER): vol.Coerce(float),
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
@@ -358,7 +359,9 @@ class VirtualPowerFlow:
return self.flow.persist_config_entry()
schema = SCHEMA_POWER_ADVANCED
if self.flow.sensor_config.get(CONF_CREATE_ENERGY_SENSOR):
if self.flow.sensor_config.get(CONF_CREATE_ENERGY_SENSOR) or self.flow.sensor_config.get(
CONF_CREATE_STANDBY_ENERGY_SENSOR,
):
schema = schema.extend(SCHEMA_SENSOR_ENERGY_OPTIONS.schema)
return await self.flow.handle_form_step(
@@ -1,5 +1,3 @@
from __future__ import annotations
from decimal import Decimal
from typing import Any, Protocol, cast
@@ -1,9 +1,11 @@
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.utility_meter.const import CONF_METER_TYPE, METER_TYPES
from homeassistant.const import UnitOfPower
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import section
from homeassistant.helpers import selector
from homeassistant.helpers.selector import NumberSelector, NumberSelectorMode
from homeassistant.util.unit_conversion import EnergyConverter
import voluptuous as vol
from custom_components.powercalc.const import (
@@ -12,6 +14,7 @@ from custom_components.powercalc.const import (
CONF_COST_SENSOR_NAMING,
CONF_CREATE_COST_SENSOR,
CONF_CREATE_ENERGY_SENSOR,
CONF_CREATE_STANDBY_ENERGY_SENSOR,
CONF_CREATE_UTILITY_METERS,
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
CONF_ENERGY_FILTER_OUTLIER_MAX,
@@ -44,6 +47,12 @@ SCHEMA_ENERGY_SENSOR_TOGGLE = vol.Schema(
},
)
SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE = vol.Schema(
{
vol.Optional(CONF_CREATE_STANDBY_ENERGY_SENSOR, default=False): selector.BooleanSelector(),
},
)
SCHEMA_COST_SENSOR_TOGGLE = vol.Schema(
{
vol.Optional(CONF_CREATE_COST_SENSOR, default=False): selector.BooleanSelector(),
@@ -61,8 +70,10 @@ SCHEMA_COST_PRICING = vol.Schema(
vol.Optional(CONF_ENERGY_PRICE): NumberSelector(
selector.NumberSelectorConfig(mode=NumberSelectorMode.BOX, step="any"),
),
# Unfiltered fallback. Prefer `build_cost_pricing_schema()` when a `hass` instance is
# available, it narrows this picker down to the price sensors actually present.
vol.Optional(CONF_ENERGY_PRICE_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(domain="sensor", device_class=SensorDeviceClass.MONETARY),
selector.EntitySelectorConfig(domain=SENSOR_DOMAIN),
),
vol.Optional(CONF_ENERGY_PRICE_SURCHARGE): NumberSelector(
selector.NumberSelectorConfig(mode=NumberSelectorMode.BOX, step="any"),
@@ -80,13 +91,71 @@ SCHEMA_GLOBAL_COST_NAMING = vol.Schema(
},
)
# Presented in the GUI as two collapsible sections (pricing and naming).
SCHEMA_GLOBAL_COST = vol.Schema(
{
vol.Required(SECTION_COST_PRICING): section(SCHEMA_COST_PRICING),
vol.Required(SECTION_COST_NAMING): section(SCHEMA_GLOBAL_COST_NAMING, {"collapsed": True}),
},
)
def is_energy_price_unit(unit: str | None) -> bool:
"""Check whether a unit of measurement expresses a price per unit of energy.
Price sensors use `<currency>/<energy unit>`, for example `€/kWh`, `EUR/kWh` or `ct/MWh`.
The currency part is free form (currency codes, symbols and cents are all in the wild), so
only the energy denominator is validated, mirroring what the cost sensor accepts at runtime.
"""
if not unit or "/" not in unit:
return False
currency, _, denominator = unit.rpartition("/")
return bool(currency.strip()) and denominator.strip() in EnergyConverter.VALID_UNITS
def _energy_price_units(hass: HomeAssistant, current_entity_id: str | None) -> list[str] | None:
"""Collect the price units to filter the energy price sensor picker on.
Returns None when the picker must not be filtered at all. The Home Assistant frontend
matches this filter on the exact unit string and hides entities without a unit, so filtering
is only safe when we can be sure it does not hide a sensor the user needs: the units are
taken from the sensors actually present rather than from a hardcoded list of currencies, and
an already configured price sensor which would not survive the filter disables it entirely.
"""
if current_entity_id:
current_state = hass.states.get(current_entity_id)
if not current_state or not is_energy_price_unit(current_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
return None
units = {
unit
for state in hass.states.async_all(SENSOR_DOMAIN)
if is_energy_price_unit(unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
}
return sorted(units) or None
def build_cost_pricing_schema(hass: HomeAssistant, current_entity_id: str | None = None) -> vol.Schema:
"""Return the pricing schema with the energy price sensor picker limited to price sensors.
`current_entity_id` is the price sensor already configured, if any, so an options flow never
hides the current selection.
"""
units = _energy_price_units(hass, current_entity_id)
if units is None:
return SCHEMA_COST_PRICING
schema: vol.Schema = SCHEMA_COST_PRICING.extend(
{
vol.Optional(CONF_ENERGY_PRICE_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(filter={"domain": SENSOR_DOMAIN, "unit_of_measurement": units}),
),
},
)
return schema
def build_global_cost_schema(hass: HomeAssistant, current_entity_id: str | None = None) -> vol.Schema:
"""Return the global cost schema, presented in the GUI as two sections (pricing and naming)."""
return vol.Schema(
{
vol.Required(SECTION_COST_PRICING): section(build_cost_pricing_schema(hass, current_entity_id)),
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_COST_PRICING.extend(SCHEMA_GLOBAL_COST_NAMING.schema)
@@ -1,5 +1,3 @@
from __future__ import annotations
from typing import Any
from custom_components.powercalc.configuration.normalization import normalize_state_trigger