259 lines
9.7 KiB
Python
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,
|
|
),
|
|
),
|
|
},
|
|
)
|