Files
HomeAssistantVS/custom_components/powercalc/flow_helper/schema.py
T

259 lines
9.7 KiB
Python

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 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 (
CONF_APPLY_TO_ALL,
CONF_COST_SENSOR_FRIENDLY_NAMING,
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,
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,
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_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(),
},
)
SECTION_COST_PRICING = "cost_pricing"
SECTION_COST_NAMING = "cost_naming"
COST_DOCS_URI = "https://docs.powercalc.nl/sensor-types/cost-sensor/"
# The pricing fields are used both for the global energy price and for the per sensor
# price override, so they are defined once and reused by both schemas.
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_DOMAIN),
),
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(),
},
)
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)
# 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(
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(
selector.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,
),
),
},
)