updated apps
This commit is contained in:
@@ -37,7 +37,11 @@ from .configuration.global_config import (
|
||||
FLAG_HAS_GLOBAL_GUI_CONFIG,
|
||||
get_global_configuration,
|
||||
)
|
||||
from .configuration.sensor_config import SENSOR_CONFIG
|
||||
from .const import (
|
||||
CONF_COST_SENSOR_FRIENDLY_NAMING,
|
||||
CONF_COST_SENSOR_NAMING,
|
||||
CONF_CREATE_COST_SENSORS,
|
||||
CONF_CREATE_DOMAIN_GROUPS,
|
||||
CONF_CREATE_ENERGY_SENSORS,
|
||||
CONF_CREATE_STANDBY_GROUP,
|
||||
@@ -50,6 +54,10 @@ from .const import (
|
||||
CONF_ENABLE_ANALYTICS,
|
||||
CONF_ENABLE_AUTODISCOVERY_DEPRECATED,
|
||||
CONF_ENERGY_INTEGRATION_METHOD,
|
||||
CONF_ENERGY_PRICE,
|
||||
CONF_ENERGY_PRICE_MULTIPLIER,
|
||||
CONF_ENERGY_PRICE_SENSOR,
|
||||
CONF_ENERGY_PRICE_SURCHARGE,
|
||||
CONF_ENERGY_SENSOR_CATEGORY,
|
||||
CONF_ENERGY_SENSOR_FRIENDLY_NAMING,
|
||||
CONF_ENERGY_SENSOR_NAMING,
|
||||
@@ -98,10 +106,9 @@ from .const import (
|
||||
SensorType,
|
||||
UnitPrefix,
|
||||
)
|
||||
from .discovery import DiscoveryManager, DiscoveryStatus
|
||||
from .discovery import DiscoveryManager, DiscoveryStatus, get_discovery_manager
|
||||
from .migrate import async_fix_legacy_profile_config_entry, async_migrate_config_entry
|
||||
from .power_profile.power_profile import DeviceType
|
||||
from .sensor import SENSOR_CONFIG
|
||||
from .sensors.group.config_entry_utils import (
|
||||
get_entries_excluding_global_config,
|
||||
get_entries_having_subgroup,
|
||||
@@ -121,6 +128,7 @@ DISCOVERY_SCHEMA = vol.Schema(
|
||||
vol.Optional(CONF_EXCLUDE_SELF_USAGE): cv.boolean,
|
||||
},
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(DOMAIN, default=dict): vol.All(
|
||||
@@ -156,6 +164,13 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
vol.Optional(CONF_ENABLE_AUTODISCOVERY_DEPRECATED): cv.boolean,
|
||||
vol.Optional(CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED): cv.boolean,
|
||||
vol.Optional(CONF_CREATE_ENERGY_SENSORS): cv.boolean,
|
||||
vol.Optional(CONF_CREATE_COST_SENSORS): cv.boolean,
|
||||
vol.Optional(CONF_ENERGY_PRICE): vol.Coerce(float),
|
||||
vol.Optional(CONF_ENERGY_PRICE_SENSOR): cv.entity_id,
|
||||
vol.Optional(CONF_ENERGY_PRICE_SURCHARGE): vol.Coerce(float),
|
||||
vol.Optional(CONF_ENERGY_PRICE_MULTIPLIER): vol.Coerce(float),
|
||||
vol.Optional(CONF_COST_SENSOR_NAMING): validate_name_pattern,
|
||||
vol.Optional(CONF_COST_SENSOR_FRIENDLY_NAMING): validate_name_pattern,
|
||||
vol.Optional(CONF_CREATE_UTILITY_METERS): cv.boolean,
|
||||
vol.Optional(CONF_UTILITY_METER_TARIFFS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_UTILITY_METER_TYPES): vol.All(cv.ensure_list, [vol.In(METER_TYPES)]),
|
||||
@@ -293,7 +308,7 @@ def register_services(hass: HomeAssistant) -> None:
|
||||
|
||||
async def _handle_update_library_service(_: ServiceCall) -> None:
|
||||
_LOGGER.info("Updating library and rediscovering devices")
|
||||
discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER]
|
||||
discovery_manager = get_discovery_manager(hass)
|
||||
await discovery_manager.update_library_and_rediscover()
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -447,7 +462,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await async_fix_legacy_profile_config_entry(hass, entry)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR, Platform.SELECT])
|
||||
# await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_entry))
|
||||
|
||||
@@ -460,7 +474,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
await apply_global_gui_configuration_changes(hass)
|
||||
|
||||
discovery_enabled = bool(entry.data.get(CONF_DISCOVERY, {}).get(CONF_ENABLED, False))
|
||||
discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER]
|
||||
discovery_manager = get_discovery_manager(hass)
|
||||
if discovery_enabled and discovery_manager.status == DiscoveryStatus.DISABLED:
|
||||
_LOGGER.debug("Enabling discovery manager based on global configuration")
|
||||
discovery_manager.enable()
|
||||
@@ -516,7 +530,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Called after a config entry is removed."""
|
||||
discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER]
|
||||
discovery_manager = get_discovery_manager(hass)
|
||||
discovery_manager.remove_initialized_flow(config_entry)
|
||||
|
||||
updated_entries: list[ConfigEntry] = []
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -12,6 +12,8 @@ import homeassistant.helpers.entity_registry as er
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
CONF_CREATE_COST_SENSOR,
|
||||
CONF_CREATE_COST_SENSORS,
|
||||
CONF_CREATE_ENERGY_SENSOR,
|
||||
CONF_CREATE_ENERGY_SENSORS,
|
||||
CONF_CREATE_GROUP,
|
||||
@@ -37,11 +39,21 @@ class SourceEntity(NamedTuple):
|
||||
device_entry: dr.DeviceEntry | None = None
|
||||
|
||||
|
||||
EXCLUDE_FROM_PARENT_CONFIG = (
|
||||
CONF_NAME,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_POWER_SENSOR_ID,
|
||||
CONF_FORCE_ENERGY_SENSOR_CREATION,
|
||||
)
|
||||
ENTITY_ID_OPTIONAL_KEYS = (CONF_DAILY_FIXED_ENERGY, CONF_POWER_SENSOR_ID, CONF_MULTI_SWITCH)
|
||||
|
||||
|
||||
def is_number(value: str) -> bool:
|
||||
"""Return whether the value can be converted to a finite float."""
|
||||
try:
|
||||
fvalue = float(value)
|
||||
except (TypeError, ValueError):
|
||||
except TypeError, ValueError:
|
||||
return False
|
||||
return math.isfinite(fvalue)
|
||||
|
||||
@@ -143,50 +155,61 @@ def _get_state_name(hass: HomeAssistant, entity_id: str) -> str | None:
|
||||
|
||||
def get_merged_sensor_configuration(*configs: dict, validate: bool = True) -> dict:
|
||||
"""Merges configuration from multiple levels (global, group, sensor) into a single dict."""
|
||||
exclude_from_merging = [
|
||||
CONF_NAME,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_POWER_SENSOR_ID,
|
||||
CONF_FORCE_ENERGY_SENSOR_CREATION,
|
||||
]
|
||||
merged_config = _merge_config_levels(configs)
|
||||
_apply_sensor_creation_defaults(merged_config)
|
||||
_apply_dummy_entity_id_default(merged_config)
|
||||
_validate_entity_id_config(merged_config, validate)
|
||||
|
||||
return merged_config
|
||||
|
||||
|
||||
def _merge_config_levels(configs: tuple[dict, ...]) -> dict:
|
||||
"""Merge config levels while keeping deepest-level-only fields local."""
|
||||
num_configs = len(configs)
|
||||
|
||||
merged_config = {}
|
||||
for i, config in enumerate(configs, 1):
|
||||
config_copy = config.copy()
|
||||
# Remove config properties which are only allowed on the deepest level
|
||||
if i < num_configs:
|
||||
for key in exclude_from_merging:
|
||||
if key in config:
|
||||
config_copy.pop(key)
|
||||
for key in EXCLUDE_FROM_PARENT_CONFIG:
|
||||
config_copy.pop(key, None)
|
||||
|
||||
merged_config.update(config_copy)
|
||||
return merged_config
|
||||
|
||||
if CONF_CREATE_ENERGY_SENSOR not in merged_config:
|
||||
merged_config[CONF_CREATE_ENERGY_SENSOR] = merged_config.get(
|
||||
CONF_CREATE_ENERGY_SENSORS,
|
||||
)
|
||||
|
||||
is_entity_id_required = not any(
|
||||
key in merged_config for key in (CONF_DAILY_FIXED_ENERGY, CONF_POWER_SENSOR_ID, CONF_MULTI_SWITCH)
|
||||
)
|
||||
def _apply_sensor_creation_defaults(config: dict) -> None:
|
||||
config.setdefault(CONF_CREATE_ENERGY_SENSOR, config.get(CONF_CREATE_ENERGY_SENSORS))
|
||||
config.setdefault(CONF_CREATE_COST_SENSOR, config.get(CONF_CREATE_COST_SENSORS))
|
||||
|
||||
if not is_entity_id_required and CONF_ENTITY_ID not in merged_config:
|
||||
merged_config[CONF_ENTITY_ID] = DUMMY_ENTITY_ID
|
||||
|
||||
sensor_type = merged_config.get(CONF_SENSOR_TYPE)
|
||||
if (
|
||||
validate
|
||||
and CONF_CREATE_GROUP not in merged_config
|
||||
and CONF_ENTITY_ID not in merged_config
|
||||
and sensor_type != SensorType.GROUP
|
||||
):
|
||||
def _apply_dummy_entity_id_default(config: dict) -> None:
|
||||
if CONF_ENTITY_ID in config:
|
||||
return
|
||||
# A standalone cost sensor has no source appliance entity, use the dummy placeholder.
|
||||
if not _is_entity_id_required(config) or config.get(CONF_SENSOR_TYPE) == SensorType.COST:
|
||||
config[CONF_ENTITY_ID] = DUMMY_ENTITY_ID
|
||||
|
||||
|
||||
def _is_entity_id_required(config: dict) -> bool:
|
||||
return not any(key in config for key in ENTITY_ID_OPTIONAL_KEYS)
|
||||
|
||||
|
||||
def _validate_entity_id_config(config: dict, validate: bool) -> None:
|
||||
if _is_missing_required_entity_id(config, validate):
|
||||
raise SensorConfigurationError(
|
||||
"You must supply an entity_id in the configuration, see the README",
|
||||
)
|
||||
|
||||
return merged_config
|
||||
|
||||
def _is_missing_required_entity_id(config: dict, validate: bool) -> bool:
|
||||
sensor_type = config.get(CONF_SENSOR_TYPE)
|
||||
return (
|
||||
validate
|
||||
and CONF_CREATE_GROUP not in config
|
||||
and CONF_ENTITY_ID not in config
|
||||
and sensor_type != SensorType.GROUP
|
||||
)
|
||||
|
||||
|
||||
def validate_name_pattern(value: str) -> str:
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.const import (
|
||||
CONF_UNIQUE_ID,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
@@ -47,6 +48,7 @@ from .const import (
|
||||
)
|
||||
from .errors import ModelNotSupportedError, StrategyConfigurationError
|
||||
from .flow_helper.common import FlowType, PowercalcFormStep, Step, fill_schema_defaults
|
||||
from .flow_helper.flows.cost import CostConfigFlow, CostOptionsFlow
|
||||
from .flow_helper.flows.daily_energy import (
|
||||
SCHEMA_DAILY_ENERGY_OPTIONS,
|
||||
DailyEnergyConfigFlow,
|
||||
@@ -72,6 +74,7 @@ from .flow_helper.flows.virtual_power import (
|
||||
)
|
||||
from .flow_helper.profile_preview import async_setup_preview as async_setup_powercalc_preview
|
||||
from .flow_helper.schema import (
|
||||
SCHEMA_COST_SENSOR_TOGGLE,
|
||||
SCHEMA_ENERGY_SENSOR_TOGGLE,
|
||||
SCHEMA_SENSOR_ENERGY_OPTIONS,
|
||||
SCHEMA_UTILITY_METER_OPTIONS,
|
||||
@@ -89,6 +92,7 @@ MENU_SENSOR_TYPE = [
|
||||
Step.MENU_GROUP,
|
||||
Step.DAILY_ENERGY,
|
||||
Step.REAL_POWER,
|
||||
Step.COST,
|
||||
]
|
||||
|
||||
MENU_OPTIONS = [
|
||||
@@ -99,14 +103,11 @@ MENU_OPTIONS = [
|
||||
Step.WLED,
|
||||
]
|
||||
|
||||
# Order matters: async_step() delegates to the first handler that defines the requested step.
|
||||
FLOW_HANDLERS: dict[FlowType, dict] = {
|
||||
FlowType.GROUP: {
|
||||
"config": GroupConfigFlow,
|
||||
"options": GroupOptionsFlow,
|
||||
},
|
||||
FlowType.DAILY_ENERGY: {
|
||||
"config": DailyEnergyConfigFlow,
|
||||
"options": DailyEnergyOptionsFlow,
|
||||
FlowType.GLOBAL_CONFIGURATION: {
|
||||
"config": GlobalConfigurationConfigFlow,
|
||||
"options": GlobalConfigurationOptionsFlow,
|
||||
},
|
||||
FlowType.LIBRARY: {
|
||||
"config": LibraryConfigFlow,
|
||||
@@ -116,14 +117,22 @@ FLOW_HANDLERS: dict[FlowType, dict] = {
|
||||
"config": VirtualPowerConfigFlow,
|
||||
"options": VirtualPowerOptionsFlow,
|
||||
},
|
||||
FlowType.GLOBAL_CONFIGURATION: {
|
||||
"config": GlobalConfigurationConfigFlow,
|
||||
"options": GlobalConfigurationOptionsFlow,
|
||||
FlowType.GROUP: {
|
||||
"config": GroupConfigFlow,
|
||||
"options": GroupOptionsFlow,
|
||||
},
|
||||
FlowType.DAILY_ENERGY: {
|
||||
"config": DailyEnergyConfigFlow,
|
||||
"options": DailyEnergyOptionsFlow,
|
||||
},
|
||||
FlowType.REAL_POWER: {
|
||||
"config": RealPowerConfigFlow,
|
||||
"options": RealPowerOptionsFlow,
|
||||
},
|
||||
FlowType.COST: {
|
||||
"config": CostConfigFlow,
|
||||
"options": CostOptionsFlow,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -145,16 +154,9 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
|
||||
self.name: str | None = None
|
||||
self.handled_steps: list[Step] = []
|
||||
|
||||
# Initialize flow handlers
|
||||
# Initialize flow handlers. Iteration order follows FLOW_HANDLERS, which async_step() relies on.
|
||||
flow_key = "options" if self.is_options_flow else "config"
|
||||
self.flow_handlers = {
|
||||
FlowType.GLOBAL_CONFIGURATION: FLOW_HANDLERS[FlowType.GLOBAL_CONFIGURATION][flow_key](self),
|
||||
FlowType.LIBRARY: FLOW_HANDLERS[FlowType.LIBRARY][flow_key](self),
|
||||
FlowType.VIRTUAL_POWER: FLOW_HANDLERS[FlowType.VIRTUAL_POWER][flow_key](self),
|
||||
FlowType.GROUP: FLOW_HANDLERS[FlowType.GROUP][flow_key](self),
|
||||
FlowType.DAILY_ENERGY: FLOW_HANDLERS[FlowType.DAILY_ENERGY][flow_key](self),
|
||||
FlowType.REAL_POWER: FLOW_HANDLERS[FlowType.REAL_POWER][flow_key](self),
|
||||
}
|
||||
self.flow_handlers = {flow_type: handlers[flow_key](self) for flow_type, handlers in FLOW_HANDLERS.items()}
|
||||
|
||||
for step in Step:
|
||||
step_method = f"async_step_{step}"
|
||||
@@ -273,7 +275,7 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
|
||||
return user_input
|
||||
|
||||
validated_input = form_step.validate_user_input(user_input)
|
||||
return await validated_input if isawaitable(validated_input) else validated_input
|
||||
return await validated_input if isawaitable(validated_input) else validated_input # ty: ignore[invalid-return-type]
|
||||
|
||||
def _store_form_step_input(
|
||||
self,
|
||||
@@ -332,7 +334,7 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
|
||||
if isinstance(form_step.schema, vol.Schema):
|
||||
return form_step.schema
|
||||
schema = await form_step.schema()
|
||||
if schema is None:
|
||||
if schema is None: # pragma: no cover
|
||||
return vol.Schema({})
|
||||
return schema
|
||||
|
||||
@@ -361,7 +363,7 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
|
||||
class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for PowerCalc."""
|
||||
|
||||
VERSION = 8
|
||||
VERSION = 9
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize options flow."""
|
||||
@@ -534,6 +536,9 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
|
||||
|
||||
def build_menu(self) -> list[Step]:
|
||||
"""Build the options menu."""
|
||||
if self.selected_sensor_type == SensorType.COST:
|
||||
return [Step.COST]
|
||||
|
||||
menu = [Step.BASIC_OPTIONS]
|
||||
if self.selected_sensor_type == SensorType.VIRTUAL_POWER:
|
||||
if self.strategy and self.should_add_strategy_option_to_menu():
|
||||
@@ -660,23 +665,32 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
|
||||
Process the provided user input against the schema.
|
||||
Update current_config with the new options. Used to save the data to the config entry later.
|
||||
"""
|
||||
for key in schema.schema:
|
||||
if isinstance(key, vol.Marker):
|
||||
key = key.schema
|
||||
if key in user_input:
|
||||
self.sensor_config[key] = user_input.get(key)
|
||||
elif key in self.sensor_config:
|
||||
self.sensor_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.
|
||||
self._process_user_input(user_input.get(base_key) or {}, val.schema)
|
||||
continue
|
||||
if base_key in user_input:
|
||||
self.sensor_config[base_key] = user_input.get(base_key)
|
||||
elif base_key in self.sensor_config:
|
||||
self.sensor_config.pop(base_key)
|
||||
|
||||
def build_basic_options_schema(self) -> vol.Schema:
|
||||
"""Build the basic options schema. depending on the selected sensor type."""
|
||||
if self.selected_sensor_type in [SensorType.REAL_POWER, SensorType.DAILY_ENERGY]:
|
||||
return SCHEMA_UTILITY_METER_TOGGLE
|
||||
return vol.Schema(
|
||||
{
|
||||
**SCHEMA_COST_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_UTILITY_METER_TOGGLE.schema,
|
||||
},
|
||||
)
|
||||
|
||||
if self.selected_sensor_type == SensorType.GROUP:
|
||||
return vol.Schema(
|
||||
{
|
||||
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_COST_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_UTILITY_METER_TOGGLE.schema,
|
||||
},
|
||||
)
|
||||
@@ -696,6 +710,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
|
||||
return schema.extend( # type: ignore[no-any-return]
|
||||
{
|
||||
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_COST_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_UTILITY_METER_TOGGLE.schema,
|
||||
},
|
||||
)
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,130 @@
|
||||
"""Convert config entry data to runtime sensor configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.configuration.normalization import normalize_playbooks, normalize_states_power
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_CALCULATION_ENABLED_CONDITION,
|
||||
CONF_CREATE_GROUP,
|
||||
CONF_DAILY_FIXED_ENERGY,
|
||||
CONF_FIXED,
|
||||
CONF_FORCE_ENERGY_SENSOR_CREATION,
|
||||
CONF_LINEAR,
|
||||
CONF_ON_TIME,
|
||||
CONF_PLAYBOOK,
|
||||
CONF_PLAYBOOKS,
|
||||
CONF_POWER,
|
||||
CONF_POWER_SENSOR_ID,
|
||||
CONF_POWER_TEMPLATE,
|
||||
CONF_SENSOR_TYPE,
|
||||
CONF_STATES_POWER,
|
||||
CONF_UTILITY_METER_OFFSET,
|
||||
CONF_VALUE,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
SensorType,
|
||||
)
|
||||
|
||||
|
||||
def convert_config_entry_to_sensor_config(config_entry: ConfigEntry, hass: HomeAssistant) -> ConfigType: # noqa: C901
|
||||
"""Convert the config entry structure to the sensor config used to create the entities."""
|
||||
sensor_config = dict(config_entry.data.copy())
|
||||
sensor_type = sensor_config.get(CONF_SENSOR_TYPE)
|
||||
|
||||
def handle_sensor_type() -> None:
|
||||
"""Handle sensor type-specific configuration."""
|
||||
if sensor_type == SensorType.GROUP:
|
||||
sensor_config[CONF_CREATE_GROUP] = sensor_config.get(CONF_NAME)
|
||||
elif sensor_type == SensorType.REAL_POWER:
|
||||
sensor_config[CONF_POWER_SENSOR_ID] = sensor_config.get(CONF_ENTITY_ID)
|
||||
sensor_config[CONF_FORCE_ENERGY_SENSOR_CREATION] = True
|
||||
|
||||
def process_template(config: dict[str, Any], template_key: str, target_key: str) -> None:
|
||||
"""Convert a template key in the config to a Template object."""
|
||||
if template_key in config:
|
||||
config[target_key] = Template(config[template_key], hass)
|
||||
del config[template_key]
|
||||
|
||||
def process_on_time(config: dict[str, Any]) -> None:
|
||||
"""Convert on_time dictionary to timedelta."""
|
||||
on_time = config.get(CONF_ON_TIME)
|
||||
config[CONF_ON_TIME] = (
|
||||
timedelta(hours=on_time["hours"], minutes=on_time["minutes"], seconds=on_time["seconds"])
|
||||
if on_time
|
||||
else timedelta(days=1)
|
||||
)
|
||||
|
||||
def process_states_power(states_power: dict[str, Any] | list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Convert state power values to Template objects where necessary."""
|
||||
return {
|
||||
key: Template(value, hass) if isinstance(value, str) and "{{" in value else value
|
||||
for key, value in normalize_states_power(states_power).items()
|
||||
}
|
||||
|
||||
def process_daily_fixed_energy() -> None:
|
||||
"""Process daily fixed energy configuration."""
|
||||
if CONF_DAILY_FIXED_ENERGY not in sensor_config:
|
||||
return
|
||||
|
||||
daily_fixed_config = copy.copy(sensor_config[CONF_DAILY_FIXED_ENERGY])
|
||||
process_template(daily_fixed_config, CONF_VALUE_TEMPLATE, CONF_VALUE)
|
||||
process_on_time(daily_fixed_config)
|
||||
sensor_config[CONF_DAILY_FIXED_ENERGY] = daily_fixed_config
|
||||
|
||||
def process_fixed_config() -> None:
|
||||
"""Process fixed energy configuration."""
|
||||
if CONF_FIXED not in sensor_config:
|
||||
return
|
||||
|
||||
fixed_config = copy.copy(sensor_config[CONF_FIXED])
|
||||
process_template(fixed_config, CONF_POWER_TEMPLATE, CONF_POWER)
|
||||
if CONF_STATES_POWER in fixed_config:
|
||||
fixed_config[CONF_STATES_POWER] = process_states_power(fixed_config[CONF_STATES_POWER])
|
||||
sensor_config[CONF_FIXED] = fixed_config
|
||||
|
||||
def process_linear_config() -> None:
|
||||
"""Process linear energy configuration."""
|
||||
if CONF_LINEAR not in sensor_config:
|
||||
return
|
||||
|
||||
linear_config = copy.copy(sensor_config[CONF_LINEAR])
|
||||
sensor_config[CONF_LINEAR] = linear_config
|
||||
|
||||
def process_calculation_enabled_condition() -> None:
|
||||
"""Process calculation enabled condition template."""
|
||||
if CONF_CALCULATION_ENABLED_CONDITION in sensor_config:
|
||||
sensor_config[CONF_CALCULATION_ENABLED_CONDITION] = Template(
|
||||
sensor_config[CONF_CALCULATION_ENABLED_CONDITION],
|
||||
hass,
|
||||
)
|
||||
|
||||
def process_utility_meter_offset() -> None:
|
||||
if CONF_UTILITY_METER_OFFSET in sensor_config:
|
||||
sensor_config[CONF_UTILITY_METER_OFFSET] = timedelta(days=sensor_config[CONF_UTILITY_METER_OFFSET])
|
||||
|
||||
def process_playbook_config() -> None:
|
||||
if CONF_PLAYBOOK not in sensor_config:
|
||||
return
|
||||
playbook_config = copy.copy(sensor_config[CONF_PLAYBOOK])
|
||||
playbook_config[CONF_PLAYBOOKS] = normalize_playbooks(playbook_config[CONF_PLAYBOOKS])
|
||||
sensor_config[CONF_PLAYBOOK] = playbook_config
|
||||
|
||||
handle_sensor_type()
|
||||
|
||||
process_daily_fixed_energy()
|
||||
process_fixed_config()
|
||||
process_linear_config()
|
||||
process_playbook_config()
|
||||
process_calculation_enabled_condition()
|
||||
process_utility_meter_offset()
|
||||
|
||||
return sensor_config
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Normalize discovery info into sensor configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import CONF_ENTITY_ID
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_GROUP_TYPE,
|
||||
CONF_SENSOR_TYPE,
|
||||
DISCOVERY_TYPE,
|
||||
DUMMY_ENTITY_ID,
|
||||
GroupType,
|
||||
PowercalcDiscoveryType,
|
||||
SensorType,
|
||||
)
|
||||
|
||||
GROUP_DISCOVERY_TYPES = {
|
||||
PowercalcDiscoveryType.DOMAIN_GROUP: GroupType.DOMAIN,
|
||||
PowercalcDiscoveryType.STANDBY_GROUP: GroupType.STANDBY,
|
||||
}
|
||||
|
||||
|
||||
def convert_discovery_info_to_sensor_config(
|
||||
discovery_info: DiscoveryInfoType,
|
||||
) -> ConfigType:
|
||||
"""Convert discovery info to sensor config."""
|
||||
group_type = GROUP_DISCOVERY_TYPES.get(discovery_info[DISCOVERY_TYPE])
|
||||
if group_type:
|
||||
discovery_info[CONF_GROUP_TYPE] = group_type
|
||||
discovery_info[CONF_SENSOR_TYPE] = SensorType.GROUP
|
||||
discovery_info[CONF_ENTITY_ID] = DUMMY_ENTITY_ID
|
||||
|
||||
return discovery_info
|
||||
@@ -8,6 +8,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_COST_SENSOR_NAMING,
|
||||
CONF_CREATE_COST_SENSORS,
|
||||
CONF_CREATE_DOMAIN_GROUPS,
|
||||
CONF_CREATE_ENERGY_SENSORS,
|
||||
CONF_CREATE_STANDBY_GROUP,
|
||||
@@ -32,6 +34,7 @@ from custom_components.powercalc.const import (
|
||||
CONF_POWER_SENSOR_PRECISION,
|
||||
CONF_UTILITY_METER_OFFSET,
|
||||
CONF_UTILITY_METER_TYPES,
|
||||
DEFAULT_COST_NAME_PATTERN,
|
||||
DEFAULT_ENERGY_INTEGRATION_METHOD,
|
||||
DEFAULT_ENERGY_NAME_PATTERN,
|
||||
DEFAULT_ENERGY_SENSOR_PRECISION,
|
||||
@@ -72,6 +75,8 @@ def get_global_configuration(hass: HomeAssistant, config: ConfigType) -> ConfigT
|
||||
CONF_IGNORE_UNAVAILABLE_STATE: False,
|
||||
CONF_CREATE_DOMAIN_GROUPS: [],
|
||||
CONF_CREATE_ENERGY_SENSORS: True,
|
||||
CONF_CREATE_COST_SENSORS: False,
|
||||
CONF_COST_SENSOR_NAMING: DEFAULT_COST_NAME_PATTERN,
|
||||
CONF_CREATE_STANDBY_GROUP: True,
|
||||
CONF_CREATE_UTILITY_METERS: False,
|
||||
CONF_DISCOVERY: {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Normalize persisted configuration shapes to runtime mappings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import CONF_ID, CONF_PATH
|
||||
|
||||
from custom_components.powercalc.const import CONF_PLAYBOOK_ID, CONF_POWER, CONF_STATE
|
||||
|
||||
|
||||
def normalize_states_power(states_power: dict[str, Any] | list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Normalize state-power config to the runtime mapping shape."""
|
||||
if isinstance(states_power, list):
|
||||
return {item[CONF_STATE]: item[CONF_POWER] for item in states_power}
|
||||
return dict(states_power)
|
||||
|
||||
|
||||
def normalize_playbooks(playbooks: dict[str, str] | list[dict[str, str]]) -> dict[str, str]:
|
||||
"""Normalize playbook config to the runtime id-path mapping shape."""
|
||||
if isinstance(playbooks, list):
|
||||
return {item[CONF_ID]: item[CONF_PATH] for item in playbooks}
|
||||
return dict(playbooks)
|
||||
|
||||
|
||||
def normalize_state_trigger(state_trigger: dict[str, str] | list[dict[str, str]]) -> dict[str, str]:
|
||||
"""Normalize playbook state-trigger config to the runtime state-playbook mapping shape."""
|
||||
if isinstance(state_trigger, list):
|
||||
return {item[CONF_STATE]: item[CONF_PLAYBOOK_ID] for item in state_trigger}
|
||||
return dict(state_trigger)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Sensor configuration schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA
|
||||
from homeassistant.components.utility_meter import max_28_days
|
||||
from homeassistant.components.utility_meter.const import METER_TYPES
|
||||
from homeassistant.const import CONF_ENTITIES, CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.common import validate_name_pattern
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_AND,
|
||||
CONF_AVAILABILITY_ENTITY,
|
||||
CONF_CALCULATION_ENABLED_CONDITION,
|
||||
CONF_COMPOSITE,
|
||||
CONF_COST,
|
||||
CONF_CREATE_COST_SENSOR,
|
||||
CONF_CREATE_ENERGY_SENSOR,
|
||||
CONF_CREATE_GROUP,
|
||||
CONF_CREATE_UTILITY_METERS,
|
||||
CONF_CUSTOM_MODEL_DIRECTORY,
|
||||
CONF_DAILY_FIXED_ENERGY,
|
||||
CONF_DELAY,
|
||||
CONF_DISABLE_STANDBY_POWER,
|
||||
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
|
||||
CONF_ENERGY_FILTER_OUTLIER_MAX,
|
||||
CONF_ENERGY_INTEGRATION_METHOD,
|
||||
CONF_ENERGY_SENSOR_CATEGORY,
|
||||
CONF_ENERGY_SENSOR_ID,
|
||||
CONF_ENERGY_SENSOR_NAMING,
|
||||
CONF_ENERGY_SENSOR_UNIT_PREFIX,
|
||||
CONF_FILTER,
|
||||
CONF_FIXED,
|
||||
CONF_FORCE_CALCULATE_GROUP_ENERGY,
|
||||
CONF_FORCE_ENERGY_SENSOR_CREATION,
|
||||
CONF_GROUP_ENERGY_START_AT_ZERO,
|
||||
CONF_GROUP_TYPE,
|
||||
CONF_HIDE_MEMBERS,
|
||||
CONF_IGNORE_UNAVAILABLE_STATE,
|
||||
CONF_INCLUDE,
|
||||
CONF_INCLUDE_NON_POWERCALC_SENSORS,
|
||||
CONF_LINEAR,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MODE,
|
||||
CONF_MODEL,
|
||||
CONF_MULTI_SWITCH,
|
||||
CONF_MULTIPLY_FACTOR,
|
||||
CONF_MULTIPLY_FACTOR_STANDBY,
|
||||
CONF_NOT,
|
||||
CONF_OR,
|
||||
CONF_PLAYBOOK,
|
||||
CONF_POWER,
|
||||
CONF_POWER_SENSOR_CATEGORY,
|
||||
CONF_POWER_SENSOR_ID,
|
||||
CONF_POWER_SENSOR_NAMING,
|
||||
CONF_SLEEP_POWER,
|
||||
CONF_STANDBY_POWER,
|
||||
CONF_SUBTRACT_ENTITIES,
|
||||
CONF_UNAVAILABLE_POWER,
|
||||
CONF_UTILITY_METER_NET_CONSUMPTION,
|
||||
CONF_UTILITY_METER_OFFSET,
|
||||
CONF_UTILITY_METER_TARIFFS,
|
||||
CONF_UTILITY_METER_TYPES,
|
||||
CONF_VARIABLES,
|
||||
CONF_WLED,
|
||||
ENERGY_INTEGRATION_METHODS,
|
||||
ENTITY_CATEGORIES,
|
||||
CalculationStrategy,
|
||||
GroupType,
|
||||
UnitPrefix,
|
||||
)
|
||||
from custom_components.powercalc.group_include.filter import FILTER_CONFIG
|
||||
from custom_components.powercalc.sensors.daily_energy import DAILY_FIXED_ENERGY_SCHEMA
|
||||
from custom_components.powercalc.strategy.composite import CONFIG_SCHEMA as COMPOSITE_SCHEMA
|
||||
from custom_components.powercalc.strategy.fixed import CONFIG_SCHEMA as FIXED_SCHEMA
|
||||
from custom_components.powercalc.strategy.linear import CONFIG_SCHEMA as LINEAR_SCHEMA
|
||||
from custom_components.powercalc.strategy.multi_switch import CONFIG_SCHEMA as MULTI_SWITCH_SCHEMA
|
||||
from custom_components.powercalc.strategy.playbook import CONFIG_SCHEMA as PLAYBOOK_SCHEMA
|
||||
from custom_components.powercalc.strategy.wled import CONFIG_SCHEMA as WLED_SCHEMA
|
||||
|
||||
MAX_GROUP_NESTING_LEVEL = 5
|
||||
|
||||
SENSOR_CONFIG = {
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_AVAILABILITY_ENTITY): cv.entity_id,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
vol.Optional(CONF_MODEL): cv.string,
|
||||
vol.Optional(CONF_MANUFACTURER): cv.string,
|
||||
vol.Optional(CONF_MODE): vol.In([cls.value for cls in CalculationStrategy]),
|
||||
vol.Optional(CONF_STANDBY_POWER): vol.Any(vol.Coerce(float), cv.template),
|
||||
vol.Optional(CONF_DISABLE_STANDBY_POWER): cv.boolean,
|
||||
vol.Optional(CONF_CUSTOM_MODEL_DIRECTORY): cv.string,
|
||||
vol.Optional(CONF_POWER_SENSOR_ID): cv.entity_id,
|
||||
vol.Optional(CONF_COST): vol.Schema({vol.Required(CONF_ENERGY_SENSOR_ID): cv.entity_id}),
|
||||
vol.Optional(CONF_FORCE_ENERGY_SENSOR_CREATION): cv.boolean,
|
||||
vol.Optional(CONF_FORCE_CALCULATE_GROUP_ENERGY): cv.boolean,
|
||||
vol.Optional(CONF_FIXED): FIXED_SCHEMA,
|
||||
vol.Optional(CONF_LINEAR): LINEAR_SCHEMA,
|
||||
vol.Optional(CONF_MULTI_SWITCH): MULTI_SWITCH_SCHEMA,
|
||||
vol.Optional(CONF_WLED): WLED_SCHEMA,
|
||||
vol.Optional(CONF_PLAYBOOK): PLAYBOOK_SCHEMA,
|
||||
vol.Optional(CONF_DAILY_FIXED_ENERGY): DAILY_FIXED_ENERGY_SCHEMA,
|
||||
vol.Optional(CONF_CREATE_ENERGY_SENSOR): cv.boolean,
|
||||
vol.Optional(CONF_CREATE_COST_SENSOR): cv.boolean,
|
||||
vol.Optional(CONF_CREATE_UTILITY_METERS): cv.boolean,
|
||||
vol.Optional(CONF_UTILITY_METER_NET_CONSUMPTION): cv.boolean,
|
||||
vol.Optional(CONF_UTILITY_METER_TARIFFS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_UTILITY_METER_TYPES): vol.All(cv.ensure_list, [vol.In(METER_TYPES)]),
|
||||
vol.Optional(CONF_UTILITY_METER_OFFSET): vol.All(cv.time_period, cv.positive_timedelta, max_28_days),
|
||||
vol.Optional(CONF_MULTIPLY_FACTOR): vol.Coerce(float),
|
||||
vol.Optional(CONF_MULTIPLY_FACTOR_STANDBY): cv.boolean,
|
||||
vol.Optional(CONF_POWER_SENSOR_NAMING): validate_name_pattern,
|
||||
vol.Optional(CONF_POWER_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES),
|
||||
vol.Optional(CONF_ENERGY_SENSOR_ID): cv.entity_id,
|
||||
vol.Optional(CONF_ENERGY_SENSOR_NAMING): validate_name_pattern,
|
||||
vol.Optional(CONF_ENERGY_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES),
|
||||
vol.Optional(CONF_ENERGY_INTEGRATION_METHOD): vol.In(ENERGY_INTEGRATION_METHODS),
|
||||
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_ENABLED): cv.boolean,
|
||||
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_MAX): cv.positive_int,
|
||||
vol.Optional(CONF_ENERGY_SENSOR_UNIT_PREFIX): vol.In([cls.value for cls in UnitPrefix]),
|
||||
vol.Optional(CONF_CREATE_GROUP): cv.string,
|
||||
vol.Optional(CONF_GROUP_ENERGY_START_AT_ZERO): cv.boolean,
|
||||
vol.Optional(CONF_GROUP_TYPE): vol.In([cls.value for cls in GroupType]),
|
||||
vol.Optional(CONF_SUBTRACT_ENTITIES): vol.All(cv.ensure_list, [cv.entity_id]),
|
||||
vol.Optional(CONF_HIDE_MEMBERS): cv.boolean,
|
||||
vol.Optional(CONF_INCLUDE): vol.Schema(
|
||||
{
|
||||
**FILTER_CONFIG.schema,
|
||||
vol.Optional(CONF_FILTER): vol.Schema(
|
||||
{
|
||||
**FILTER_CONFIG.schema,
|
||||
vol.Optional(CONF_OR): vol.All(cv.ensure_list, [FILTER_CONFIG]),
|
||||
vol.Optional(CONF_AND): vol.All(cv.ensure_list, [FILTER_CONFIG]),
|
||||
vol.Optional(CONF_NOT): vol.All(cv.ensure_list, [FILTER_CONFIG]),
|
||||
},
|
||||
),
|
||||
vol.Optional(CONF_INCLUDE_NON_POWERCALC_SENSORS, default=True): cv.boolean,
|
||||
},
|
||||
),
|
||||
vol.Optional(CONF_IGNORE_UNAVAILABLE_STATE): cv.boolean,
|
||||
vol.Optional(CONF_CALCULATION_ENABLED_CONDITION): cv.template,
|
||||
vol.Optional(CONF_SLEEP_POWER): vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_POWER): vol.Coerce(float),
|
||||
vol.Required(CONF_DELAY): cv.positive_int,
|
||||
},
|
||||
),
|
||||
vol.Optional(CONF_UNAVAILABLE_POWER): vol.Coerce(float),
|
||||
vol.Optional(CONF_COMPOSITE): COMPOSITE_SCHEMA,
|
||||
vol.Optional(CONF_VARIABLES): vol.Schema({cv.string: cv.string}),
|
||||
}
|
||||
|
||||
|
||||
def build_nested_configuration_schema(schema: dict, iteration: int = 0) -> dict:
|
||||
if iteration == MAX_GROUP_NESTING_LEVEL:
|
||||
return schema
|
||||
iteration += 1
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(CONF_ENTITIES): vol.All(
|
||||
cv.ensure_list,
|
||||
[build_nested_configuration_schema(schema.copy(), iteration)],
|
||||
),
|
||||
},
|
||||
)
|
||||
return schema
|
||||
|
||||
|
||||
SENSOR_CONFIG = build_nested_configuration_schema(SENSOR_CONFIG)
|
||||
|
||||
PLATFORM_SCHEMA = vol.All(
|
||||
cv.has_at_least_one_key(
|
||||
CONF_ENTITY_ID,
|
||||
CONF_POWER_SENSOR_ID,
|
||||
CONF_ENTITIES,
|
||||
CONF_INCLUDE,
|
||||
CONF_DAILY_FIXED_ENERGY,
|
||||
CONF_COST,
|
||||
),
|
||||
SENSOR_PLATFORM_SCHEMA.extend(SENSOR_CONFIG),
|
||||
)
|
||||
@@ -14,10 +14,11 @@ from homeassistant.const import (
|
||||
STATE_OPEN,
|
||||
STATE_STANDBY,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityCategory,
|
||||
)
|
||||
|
||||
MIN_HA_VERSION = "2026.1"
|
||||
MIN_HA_VERSION = "2026.1.0"
|
||||
|
||||
BUILT_IN_LIBRARY_DIR = "powercalc_profiles"
|
||||
|
||||
@@ -55,6 +56,7 @@ DUMMY_ENTITY_ID = "sensor.dummy"
|
||||
|
||||
CONF_ALL = "all"
|
||||
CONF_AND = "and"
|
||||
CONF_APPLY_TO_ALL = "apply_to_all"
|
||||
CONF_ENABLE_ANALYTICS = "enable_analytics"
|
||||
CONF_AREA = "area"
|
||||
CONF_AUTOSTART = "autostart"
|
||||
@@ -63,6 +65,12 @@ CONF_CALCULATION_ENABLED_CONDITION = "calculation_enabled_condition"
|
||||
CONF_CALIBRATE = "calibrate"
|
||||
CONF_CATEGORY = "category"
|
||||
CONF_COMPOSITE = "composite"
|
||||
CONF_COST = "cost"
|
||||
CONF_COST_SENSOR_FRIENDLY_NAMING = "cost_sensor_friendly_naming"
|
||||
CONF_COST_SENSOR_NAMING = "cost_sensor_naming"
|
||||
CONF_COST_SENSOR_PRECISION = "cost_sensor_precision"
|
||||
CONF_CREATE_COST_SENSOR = "create_cost_sensor"
|
||||
CONF_CREATE_COST_SENSORS = "create_cost_sensors"
|
||||
CONF_CREATE_DOMAIN_GROUPS = "create_domain_groups"
|
||||
CONF_CREATE_ENERGY_SENSOR = "create_energy_sensor"
|
||||
CONF_CREATE_ENERGY_SENSORS = "create_energy_sensors"
|
||||
@@ -87,6 +95,10 @@ CONF_GROUP_UPDATE_INTERVAL_DEPRECATED = "group_update_interval"
|
||||
CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED = "force_update_frequency"
|
||||
|
||||
CONF_ENERGY_INTEGRATION_METHOD = "energy_integration_method"
|
||||
CONF_ENERGY_PRICE = "energy_price"
|
||||
CONF_ENERGY_PRICE_MULTIPLIER = "energy_price_multiplier"
|
||||
CONF_ENERGY_PRICE_SENSOR = "energy_price_sensor"
|
||||
CONF_ENERGY_PRICE_SURCHARGE = "energy_price_surcharge"
|
||||
CONF_ENERGY_SENSOR_CATEGORY = "energy_sensor_category"
|
||||
CONF_ENERGY_SENSOR_FRIENDLY_NAMING = "energy_sensor_friendly_naming"
|
||||
CONF_ENERGY_SENSOR_ID = "energy_sensor_id"
|
||||
@@ -218,6 +230,8 @@ DEFAULT_ENERGY_NAME_PATTERN = "{} energy"
|
||||
DEFAULT_SELF_USAGE_ENERGY_NAME_PATTERN = "{} Device Energy"
|
||||
DEFAULT_ENERGY_SENSOR_PRECISION = 4
|
||||
DEFAULT_ENERGY_UNIT_PREFIX = UnitPrefix.KILO
|
||||
DEFAULT_COST_NAME_PATTERN = "{} cost"
|
||||
DEFAULT_COST_SENSOR_PRECISION = 4
|
||||
DEFAULT_ENTITY_CATEGORY: str | None = None
|
||||
DEFAULT_UTILITY_METER_TYPES = [DAILY, WEEKLY, MONTHLY]
|
||||
|
||||
@@ -241,6 +255,7 @@ ATTR_SOURCE_ENTITY = "source_entity"
|
||||
ATTR_SOURCE_DOMAIN = "source_domain"
|
||||
|
||||
SERVICE_ACTIVATE_PLAYBOOK = "activate_playbook"
|
||||
SERVICE_CALIBRATE_COST = "calibrate_cost"
|
||||
SERVICE_CALIBRATE_UTILITY_METER = "calibrate_utility_meter"
|
||||
SERVICE_CALIBRATE_ENERGY = "calibrate_energy"
|
||||
SERVICE_CHANGE_GUI_CONFIGURATION = "change_gui_config"
|
||||
@@ -248,6 +263,7 @@ SERVICE_DEBUG_GROUP = "debug_group"
|
||||
SERVICE_GET_ACTIVE_PLAYBOOK = "get_active_playbook"
|
||||
SERVICE_GET_GROUP_ENTITIES = "get_group_entities"
|
||||
SERVICE_INCREASE_DAILY_ENERGY = "increase_daily_energy"
|
||||
SERVICE_RESET_COST = "reset_cost"
|
||||
SERVICE_RESET_ENERGY = "reset_energy"
|
||||
SERVICE_STOP_PLAYBOOK = "stop_playbook"
|
||||
SERVICE_SWITCH_SUB_PROFILE = "switch_sub_profile"
|
||||
@@ -257,6 +273,7 @@ SERVICE_RELOAD = "reload"
|
||||
SIGNAL_POWER_SENSOR_STATE_CHANGE = "powercalc_power_sensor_state_change"
|
||||
|
||||
OFF_STATES = {STATE_OFF, STATE_STANDBY, STATE_UNAVAILABLE}
|
||||
UNAVAILABLE_STATES = frozenset({STATE_UNAVAILABLE, STATE_UNKNOWN})
|
||||
OFF_STATES_BY_DOMAIN: dict[str, set[str]] = {
|
||||
cover.DOMAIN: {STATE_CLOSED, STATE_OPEN},
|
||||
device_tracker.DOMAIN: {STATE_NOT_HOME},
|
||||
@@ -287,6 +304,7 @@ class SensorType(StrEnum):
|
||||
VIRTUAL_POWER = "virtual_power"
|
||||
GROUP = "group"
|
||||
REAL_POWER = "real_power"
|
||||
COST = "cost"
|
||||
|
||||
|
||||
class PowercalcDiscoveryType(StrEnum):
|
||||
@@ -311,6 +329,7 @@ class EntityType(StrEnum):
|
||||
|
||||
POWER_SENSOR = "power_sensor"
|
||||
ENERGY_SENSOR = "energy_sensor"
|
||||
COST_SENSOR = "cost_sensor"
|
||||
UTILITY_METER = "utility_meter"
|
||||
TARIFF_SELECT = "tariff_select"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@@ -1,119 +1,118 @@
|
||||
import logging
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE,
|
||||
__version__ as HA_VERSION, # noqa: N812
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import CONF_DEVICE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry
|
||||
from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo
|
||||
from homeassistant.helpers.device import async_entity_id_to_device
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.entity import Entity
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.const import CONF_SENSOR_TYPE, SensorType
|
||||
from custom_components.powercalc.sensors.abstract import BaseEntity
|
||||
from custom_components.powercalc.const import CONF_AREA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def attach_entities_to_source_device(
|
||||
async def attach_entities_to_resolved_device(
|
||||
config_entry: ConfigEntry | None,
|
||||
entities_to_add: list[Entity],
|
||||
hass: HomeAssistant,
|
||||
source_entity: SourceEntity | None,
|
||||
sensor_config: ConfigType | None = None,
|
||||
) -> None:
|
||||
"""Set the entity to same device as the source entity, if any available."""
|
||||
|
||||
device_entry = source_entity.device_entry if source_entity else None
|
||||
if not device_entry and config_entry:
|
||||
device_id = config_entry.data.get(CONF_DEVICE)
|
||||
if device_id:
|
||||
device_entry = device_registry.async_get(hass).async_get(device_id)
|
||||
"""Set entities to the configured or source device, if any available."""
|
||||
|
||||
device_entry = get_device_entry(hass, sensor_config, source_entity, config_entry)
|
||||
if not device_entry:
|
||||
if config_entry:
|
||||
sensor_type = SensorType(config_entry.data.get(CONF_SENSOR_TYPE, SensorType.VIRTUAL_POWER))
|
||||
if sensor_type == SensorType.GROUP:
|
||||
remove_stale_devices(hass, config_entry, None)
|
||||
return
|
||||
|
||||
if config_entry:
|
||||
bind_config_entry_to_device(hass, config_entry, device_entry)
|
||||
|
||||
for entity in (entity for entity in entities_to_add if isinstance(entity, BaseEntity)):
|
||||
for entity in entities_to_add:
|
||||
try:
|
||||
if AwesomeVersion(HA_VERSION) >= AwesomeVersion("2025.8.0") and config_entry:
|
||||
entity.device_entry = device_entry
|
||||
else:
|
||||
entity.source_device_id = device_entry.id # type: ignore
|
||||
entity.device_entry = device_entry
|
||||
except AttributeError: # pragma: no cover
|
||||
_LOGGER.error("%s: Cannot set device id on entity", entity.entity_id)
|
||||
|
||||
|
||||
def bind_config_entry_to_device(hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry) -> None:
|
||||
"""
|
||||
When the user selected a specific device in the config flow, bind the config entry to that device
|
||||
This will let HA bind all the powercalc entities for that config entry to the concerning device
|
||||
"""
|
||||
|
||||
if config_entry.entry_id not in device_entry.config_entries:
|
||||
device_reg = device_registry.async_get(hass)
|
||||
device_reg.async_update_device(
|
||||
device_entry.id,
|
||||
add_config_entry_id=config_entry.entry_id,
|
||||
)
|
||||
|
||||
remove_stale_devices(hass, config_entry, device_entry.id)
|
||||
|
||||
|
||||
def remove_stale_devices(
|
||||
def get_device_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
device_id: str | None,
|
||||
) -> None:
|
||||
"""Remove powercalc config entries from old devices."""
|
||||
device_reg = device_registry.async_get(hass)
|
||||
device_entries = device_registry.async_entries_for_config_entry(
|
||||
device_reg,
|
||||
config_entry.entry_id,
|
||||
)
|
||||
|
||||
stale_devices = [device_entry for device_entry in device_entries if device_entry.id != device_id]
|
||||
|
||||
for device_entry in stale_devices:
|
||||
device_reg.async_update_device(
|
||||
device_entry.id,
|
||||
remove_config_entry_id=config_entry.entry_id,
|
||||
)
|
||||
|
||||
|
||||
def get_device_info(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
source_entity: SourceEntity | None,
|
||||
) -> DeviceInfo | None:
|
||||
sensor_config: ConfigType | None = None,
|
||||
source_entity: SourceEntity | None = None,
|
||||
config_entry: ConfigEntry | None = None,
|
||||
) -> DeviceEntry | None:
|
||||
"""
|
||||
Get device info for a given powercalc entity configuration.
|
||||
Get device entry for a given powercalc entity configuration.
|
||||
Prefer user configured device, when it is not set fallback to the same device as the source entity
|
||||
"""
|
||||
device_id = sensor_config.get(CONF_DEVICE)
|
||||
device = None
|
||||
device_id = None
|
||||
if sensor_config is not None:
|
||||
device_id = sensor_config.get(CONF_DEVICE)
|
||||
if device_id is None and config_entry is not None:
|
||||
device_id = config_entry.data.get(CONF_DEVICE)
|
||||
if device_id is not None:
|
||||
device_reg = device_registry.async_get(hass)
|
||||
device = device_reg.async_get(device_id)
|
||||
elif source_entity:
|
||||
device = source_entity.device_entry
|
||||
return device_registry.async_get(hass).async_get(device_id)
|
||||
|
||||
if device is None:
|
||||
return None
|
||||
if source_entity:
|
||||
return source_entity.device_entry or async_entity_id_to_device(hass, source_entity.entity_id)
|
||||
|
||||
if not device.identifiers and not device.connections:
|
||||
return None
|
||||
return None
|
||||
|
||||
return DeviceInfo(
|
||||
identifiers=device.identifiers,
|
||||
connections=device.connections,
|
||||
)
|
||||
|
||||
@callback
|
||||
def bind_entity_to_registry_metadata(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str | None,
|
||||
device_entry: DeviceEntry | None,
|
||||
sensor_config: ConfigType | None,
|
||||
) -> None:
|
||||
"""Bind a Powercalc entity to configured registry metadata."""
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
entity_entry = entity_reg.async_get(entity_id)
|
||||
if entity_entry is None:
|
||||
return
|
||||
|
||||
bind_entity_to_device(entity_reg, entity_entry, device_entry)
|
||||
bind_entity_to_area(entity_reg, entity_entry, sensor_config.get(CONF_AREA) if sensor_config else None)
|
||||
|
||||
|
||||
@callback
|
||||
def bind_entity_to_device(
|
||||
entity_reg: er.EntityRegistry,
|
||||
entity_entry: RegistryEntry,
|
||||
device_entry: DeviceEntry | None,
|
||||
) -> None:
|
||||
"""Bind a Powercalc entity to the resolved device."""
|
||||
# Home Assistant only consumes entity.device_entry while creating registry
|
||||
# entries for config-entry platforms. YAML/platform entities need this
|
||||
# registry update after they have been added.
|
||||
if device_entry is None:
|
||||
return
|
||||
|
||||
if entity_entry.config_entry_id is not None or entity_entry.device_id == device_entry.id:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Binding %s to device %s", entity_entry.entity_id, device_entry.id)
|
||||
entity_reg.async_update_entity(entity_entry.entity_id, device_id=device_entry.id)
|
||||
|
||||
|
||||
@callback
|
||||
def bind_entity_to_area(
|
||||
entity_reg: er.EntityRegistry,
|
||||
entity_entry: RegistryEntry,
|
||||
area_id: str | None,
|
||||
) -> None:
|
||||
"""Bind a Powercalc entity to the configured area."""
|
||||
if not area_id:
|
||||
return
|
||||
|
||||
if entity_entry.area_id == area_id:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Binding %s to area %s", entity_entry.entity_id, area_id)
|
||||
entity_reg.async_update_entity(entity_entry.entity_id, area_id=area_id)
|
||||
|
||||
@@ -51,35 +51,38 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_DiscoverySourceT = TypeVar("_DiscoverySourceT", er.RegistryEntry, dr.DeviceEntry)
|
||||
|
||||
|
||||
async def get_power_profile_by_source_entity(hass: HomeAssistant, source_entity: SourceEntity) -> PowerProfile | None:
|
||||
"""Given a certain entity, lookup the manufacturer and model and return the power profile."""
|
||||
def get_discovery_manager(hass: HomeAssistant) -> DiscoveryManager:
|
||||
"""Return the shared discovery manager, creating a throwaway one when not yet set up."""
|
||||
try:
|
||||
discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER]
|
||||
return hass.data[DOMAIN][DATA_DISCOVERY_MANAGER] # type: ignore[no-any-return]
|
||||
except KeyError:
|
||||
discovery_manager = DiscoveryManager(hass, {})
|
||||
return DiscoveryManager(hass, {})
|
||||
|
||||
|
||||
async def _get_power_profile_by_source(
|
||||
hass: HomeAssistant,
|
||||
source_entity: SourceEntity,
|
||||
discovery_by: DiscoveryBy,
|
||||
) -> PowerProfile | None:
|
||||
"""Look up a power profile for a source entity, discovered either by entity or by device."""
|
||||
discovery_manager = get_discovery_manager(hass)
|
||||
model_info = await discovery_manager.extract_model_info_from_device_info(source_entity.entity_entry)
|
||||
if not model_info:
|
||||
return None
|
||||
profiles = await discovery_manager.find_power_profiles(model_info, source_entity, DiscoveryBy.ENTITY)
|
||||
profiles = await discovery_manager.find_power_profiles(model_info, source_entity, discovery_by)
|
||||
return profiles[0] if profiles else None
|
||||
|
||||
|
||||
async def get_power_profile_by_source_entity(hass: HomeAssistant, source_entity: SourceEntity) -> PowerProfile | None:
|
||||
"""Given a certain entity, lookup the manufacturer and model and return the power profile."""
|
||||
return await _get_power_profile_by_source(hass, source_entity, DiscoveryBy.ENTITY)
|
||||
|
||||
|
||||
async def get_power_profile_by_source_device(hass: HomeAssistant, source_entity: SourceEntity) -> PowerProfile | None:
|
||||
"""Look up a device-discovered power profile for a source entity's device."""
|
||||
if not source_entity.device_entry or not source_entity.entity_entry:
|
||||
return None
|
||||
|
||||
try:
|
||||
discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER]
|
||||
except KeyError:
|
||||
discovery_manager = DiscoveryManager(hass, {})
|
||||
|
||||
model_info = await discovery_manager.extract_model_info_from_device_info(source_entity.entity_entry)
|
||||
if not model_info:
|
||||
return None
|
||||
|
||||
profiles = await discovery_manager.find_power_profiles(model_info, source_entity, DiscoveryBy.DEVICE)
|
||||
return profiles[0] if profiles else None
|
||||
return await _get_power_profile_by_source(hass, source_entity, DiscoveryBy.DEVICE)
|
||||
|
||||
|
||||
class DiscoveryStatus(StrEnum):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,6 +11,7 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_DOMAIN, EntityCategory
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers import area_registry, device_registry, entity_registry, floor_registry, label_registry
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.template import Template
|
||||
@@ -148,8 +149,6 @@ class GroupFilter(EntityFilter):
|
||||
|
||||
class StandardGroupFilter(EntityFilter):
|
||||
def __init__(self, hass: HomeAssistant, group_id: str) -> None:
|
||||
entity_reg = entity_registry.async_get(hass)
|
||||
entity_reg.async_get(group_id)
|
||||
group_state = hass.states.get(group_id)
|
||||
if group_state is None:
|
||||
raise SensorConfigurationError(f"Group state {group_id} not found")
|
||||
@@ -161,14 +160,7 @@ class StandardGroupFilter(EntityFilter):
|
||||
|
||||
class LightGroupFilter(EntityFilter):
|
||||
def __init__(self, hass: HomeAssistant, group_id: str) -> None:
|
||||
light_component = cast(EntityComponent, hass.data.get(LIGHT_DOMAIN))
|
||||
light_group = next(
|
||||
filter(
|
||||
lambda entity: entity.entity_id == group_id,
|
||||
light_component.entities,
|
||||
),
|
||||
None,
|
||||
)
|
||||
light_group = self._find_light_group(hass, group_id)
|
||||
if light_group is None or light_group.platform.platform_name != GROUP_DOMAIN:
|
||||
raise SensorConfigurationError(f"Light group {group_id} not found")
|
||||
|
||||
@@ -177,6 +169,17 @@ class LightGroupFilter(EntityFilter):
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
return entity.entity_id in self.entity_ids
|
||||
|
||||
@staticmethod
|
||||
def _find_light_group(hass: HomeAssistant, group_entity_id: str) -> Entity | None:
|
||||
light_component = cast(EntityComponent, hass.data.get(LIGHT_DOMAIN))
|
||||
return next(
|
||||
filter(
|
||||
lambda entity: entity.entity_id == group_entity_id,
|
||||
light_component.entities,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def find_all_entity_ids_recursively(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -184,14 +187,7 @@ class LightGroupFilter(EntityFilter):
|
||||
all_entity_ids: list[str],
|
||||
) -> list[str]:
|
||||
entity_reg = entity_registry.async_get(hass)
|
||||
light_component = cast(EntityComponent, hass.data.get(LIGHT_DOMAIN))
|
||||
light_group = next(
|
||||
filter(
|
||||
lambda entity: entity.entity_id == group_entity_id,
|
||||
light_component.entities,
|
||||
),
|
||||
None,
|
||||
)
|
||||
light_group = self._find_light_group(hass, group_entity_id)
|
||||
|
||||
entity_ids: list[str] = light_group.extra_state_attributes.get(ATTR_ENTITY_ID) # type: ignore
|
||||
for entity_id in entity_ids:
|
||||
@@ -373,7 +369,7 @@ class CompositeFilter(EntityFilter):
|
||||
self.operator = operator
|
||||
|
||||
def is_valid(self, entity: RegistryEntry) -> bool:
|
||||
evaluations = [entity_filter.is_valid(entity) for entity_filter in self.filters]
|
||||
evaluations = (entity_filter.is_valid(entity) for entity_filter in self.filters)
|
||||
if self.operator == FilterOperator.OR:
|
||||
return any(evaluations)
|
||||
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
from collections.abc import Callable, Coroutine, Iterable, Iterator
|
||||
import decimal
|
||||
from decimal import Decimal
|
||||
from functools import wraps
|
||||
import logging
|
||||
import os.path
|
||||
import re
|
||||
from typing import Any, NamedTuple, TypeVar
|
||||
from typing import Any, NamedTuple, TypeVar, cast
|
||||
import uuid
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers import entity_registry
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
@@ -32,28 +28,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PLACEHOLDER_REGEX = re.compile(r"\[\[\s*([A-Za-z_]\w*(?::[A-Za-z_]\w*)*)\s*\]\]")
|
||||
|
||||
|
||||
def evaluate_power(power: Template | Decimal | float) -> Decimal | None:
|
||||
"""When power is a template render it."""
|
||||
|
||||
if isinstance(power, Decimal):
|
||||
return power
|
||||
|
||||
try:
|
||||
if isinstance(power, Template):
|
||||
try:
|
||||
power = power.async_render()
|
||||
except TemplateError as ex:
|
||||
_LOGGER.error("Could not render power template %s: %s", power, ex)
|
||||
return None
|
||||
if power == "unknown":
|
||||
return None
|
||||
|
||||
return Decimal(power) # type: ignore[arg-type]
|
||||
except (decimal.DecimalException, ValueError):
|
||||
_LOGGER.error("Could not convert power value %s to decimal", power)
|
||||
return None
|
||||
|
||||
|
||||
def get_library_path(sub_path: str = "") -> str:
|
||||
"""Get the path to the library file."""
|
||||
base_path = os.path.join(os.path.dirname(__file__), "../../profile_library")
|
||||
@@ -138,9 +112,18 @@ def async_cache[R](func: Callable[..., Coroutine[Any, Any, R]]) -> Callable[...,
|
||||
cache[cache_key] = result
|
||||
return result
|
||||
|
||||
cast(Any, wrapper).cache_clear = cache.clear
|
||||
return wrapper
|
||||
|
||||
|
||||
def clear_async_cache(func: Callable[..., Coroutine[Any, Any, Any]]) -> None:
|
||||
"""Clear a function wrapped with async_cache."""
|
||||
target = getattr(func, "__func__", func)
|
||||
cache_clear = getattr(target, "cache_clear", None)
|
||||
if callable(cache_clear):
|
||||
cache_clear()
|
||||
|
||||
|
||||
def collect_placeholders(data: list | str | dict[str, Any]) -> set[str]:
|
||||
found: set[str] = set()
|
||||
if isinstance(data, dict):
|
||||
@@ -226,14 +209,6 @@ def _resolve_related_entity_by_device_class(
|
||||
return get_related_entity_by_device_class(hass, source_entity, device_class)
|
||||
|
||||
|
||||
def _resolve_related_entity_by_translation_key(
|
||||
hass: HomeAssistant,
|
||||
source_entity: SourceEntity,
|
||||
translation_key: str,
|
||||
) -> str | None:
|
||||
return get_related_entity_by_translation_key(hass, source_entity, translation_key)
|
||||
|
||||
|
||||
RELATED_ENTITY_PLACEHOLDER_DEFINITIONS = (
|
||||
RelatedEntityPlaceholderDefinition(
|
||||
PLACEHOLDER_ENTITY_BY_DEVICE_CLASS,
|
||||
@@ -243,7 +218,11 @@ RELATED_ENTITY_PLACEHOLDER_DEFINITIONS = (
|
||||
RelatedEntityPlaceholderDefinition(
|
||||
PLACEHOLDER_ENTITY_BY_TRANSLATION_KEY,
|
||||
"translation key",
|
||||
_resolve_related_entity_by_translation_key,
|
||||
lambda hass, source_entity, translation_key: get_related_entity_by_translation_key(
|
||||
hass,
|
||||
source_entity,
|
||||
translation_key,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"calibrate_energy": {
|
||||
"service": "mdi:wrench"
|
||||
},
|
||||
"calibrate_cost": {
|
||||
"service": "mdi:wrench"
|
||||
},
|
||||
"change_gui_config": {
|
||||
"service": "mdi:cogs"
|
||||
},
|
||||
@@ -30,6 +33,9 @@
|
||||
"reset_energy": {
|
||||
"service": "mdi:restore"
|
||||
},
|
||||
"reset_cost": {
|
||||
"service": "mdi:restore"
|
||||
},
|
||||
"stop_playbook": {
|
||||
"service": "mdi:stop"
|
||||
},
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"requirements": [
|
||||
"numpy>=1.21.1"
|
||||
],
|
||||
"version": "v1.21.2"
|
||||
"version": "v1.22.0"
|
||||
}
|
||||
@@ -3,7 +3,8 @@ from datetime import timedelta
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ENABLED, CONF_ID, CONF_PATH, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers import device_registry, issue_registry as ir
|
||||
from homeassistant.helpers.helper_integration import async_remove_helper_config_entry_from_source_device
|
||||
from homeassistant.helpers.issue_registry import async_create_issue
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
@@ -50,7 +51,7 @@ async def async_migrate_config_entry(hass: HomeAssistant, config_entry: ConfigEn
|
||||
if version <= 3:
|
||||
_migrate_playbook_trigger(data)
|
||||
|
||||
if version <= 4 and config_entry.entry_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID:
|
||||
if version <= 4 and config_entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID:
|
||||
_migrate_global_discovery_config(data)
|
||||
|
||||
if version <= 5:
|
||||
@@ -62,7 +63,24 @@ async def async_migrate_config_entry(hass: HomeAssistant, config_entry: ConfigEn
|
||||
if version <= 7:
|
||||
_migrate_invalid_power_sensor_category(data)
|
||||
|
||||
hass.config_entries.async_update_entry(config_entry, data=data, version=8)
|
||||
if version <= 8:
|
||||
_remove_config_entry_from_devices(hass, config_entry)
|
||||
|
||||
hass.config_entries.async_update_entry(config_entry, data=data, version=9)
|
||||
|
||||
|
||||
def _remove_config_entry_from_devices(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""
|
||||
Remove powercalc config entry from devices.
|
||||
See: https://developers.home-assistant.io/blog/2025/07/18/updated-pattern-for-helpers-linking-to-devices/
|
||||
"""
|
||||
device_reg = device_registry.async_get(hass)
|
||||
for device_entry in device_registry.async_entries_for_config_entry(device_reg, config_entry.entry_id):
|
||||
async_remove_helper_config_entry_from_source_device(
|
||||
hass,
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=device_entry.id,
|
||||
)
|
||||
|
||||
|
||||
def _migrate_power_template(data: dict) -> None:
|
||||
@@ -78,16 +96,22 @@ def _migrate_playbook_trigger(data: dict) -> None:
|
||||
|
||||
|
||||
def _migrate_global_discovery_config(data: dict) -> None:
|
||||
deprecated_keys = [
|
||||
CONF_ENABLE_AUTODISCOVERY_DEPRECATED,
|
||||
CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED,
|
||||
CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED,
|
||||
]
|
||||
# Nothing to convert when there are no legacy keys and the new format is already present;
|
||||
# avoid clobbering an existing discovery config.
|
||||
if not any(key in data for key in deprecated_keys) and CONF_DISCOVERY in data:
|
||||
return
|
||||
|
||||
data[CONF_DISCOVERY] = {
|
||||
CONF_ENABLED: data.get(CONF_ENABLE_AUTODISCOVERY_DEPRECATED, True),
|
||||
CONF_EXCLUDE_DEVICE_TYPES: data.get(CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED, []),
|
||||
CONF_EXCLUDE_SELF_USAGE: data.get(CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED, False),
|
||||
}
|
||||
for key in [
|
||||
CONF_ENABLE_AUTODISCOVERY_DEPRECATED,
|
||||
CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED,
|
||||
CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED,
|
||||
]:
|
||||
for key in deprecated_keys:
|
||||
data.pop(key, None)
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,6 @@ from functools import partial
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, cast
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -154,8 +153,7 @@ class LocalLoader(Loader):
|
||||
|
||||
manufacturer = manufacturer_dir.lower()
|
||||
for model_dir in next(os.walk(manufacturer_path))[1]:
|
||||
pattern = re.compile(r"^\..*")
|
||||
if pattern.match(model_dir):
|
||||
if model_dir.startswith("."):
|
||||
continue
|
||||
|
||||
model_path = os.path.join(manufacturer_path, model_dir)
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.helpers.storage import STORAGE_DIR
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
from custom_components.powercalc.const import API_URL, BUILT_IN_LIBRARY_DIR, DOMAIN
|
||||
from custom_components.powercalc.helpers import async_cache
|
||||
from custom_components.powercalc.helpers import async_cache, clear_async_cache
|
||||
from custom_components.powercalc.power_profile.error import LibraryLoadingError, ProfileDownloadError
|
||||
from custom_components.powercalc.power_profile.loader.protocol import Loader
|
||||
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy
|
||||
@@ -66,6 +66,7 @@ class RemoteLoader(Loader):
|
||||
integration = await async_get_integration(self.hass, DOMAIN)
|
||||
powercalc_version = AwesomeVersion(str(integration.version))
|
||||
|
||||
self._clear_caches()
|
||||
self.library_contents = await self.load_library_json()
|
||||
self.profile_hashes = await self.hass.async_add_executor_job(self._load_profile_hashes)
|
||||
|
||||
@@ -123,6 +124,15 @@ class RemoteLoader(Loader):
|
||||
self.manufacturer_models[manufacturer_name] = kept_models
|
||||
self.model_lookup[manufacturer_name] = lookup
|
||||
|
||||
def _clear_caches(self) -> None:
|
||||
"""Clear cached lookups backed by mutable library state."""
|
||||
clear_async_cache(self.get_manufacturer_listing)
|
||||
clear_async_cache(self.find_manufacturers)
|
||||
clear_async_cache(self.get_model_listing)
|
||||
clear_async_cache(self.find_model)
|
||||
clear_async_cache(self.find_model_migration)
|
||||
clear_async_cache(self.load_model)
|
||||
|
||||
async def load_library_json(self) -> dict[str, Any]:
|
||||
"""Load library.json file"""
|
||||
|
||||
@@ -191,7 +201,7 @@ class RemoteLoader(Loader):
|
||||
@async_cache
|
||||
async def find_manufacturers(self, search: str) -> set[str]:
|
||||
"""Find the manufacturer in the library."""
|
||||
return self.manufacturer_lookup.get(search, set())
|
||||
return self.manufacturer_lookup.get(search.lower(), set())
|
||||
|
||||
@async_cache
|
||||
async def get_model_listing(
|
||||
@@ -279,7 +289,7 @@ class RemoteLoader(Loader):
|
||||
"""Retrieve model info, or raise an error if not found."""
|
||||
model_info = self.model_infos.get(f"{manufacturer}/{model}")
|
||||
if not model_info:
|
||||
raise LibraryLoadingError("Model not found in library: %s/%s", manufacturer, model)
|
||||
raise LibraryLoadingError(f"Model not found in library: {manufacturer}/{model}")
|
||||
return model_info
|
||||
|
||||
async def _needs_update(
|
||||
@@ -424,10 +434,14 @@ class RemoteLoader(Loader):
|
||||
except (TimeoutError, aiohttp.ClientError) as e:
|
||||
raise ProfileDownloadError(f"Failed to download profile: {manufacturer}/{model}") from e
|
||||
|
||||
def _get_profile_hashes_path(self) -> str:
|
||||
"""Retrieve the local storage path for the profile hashes file."""
|
||||
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, ".profile_hashes"))
|
||||
|
||||
def _load_profile_hashes(self) -> dict[str, str]:
|
||||
"""Load profile hashes from local storage"""
|
||||
|
||||
path = self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, ".profile_hashes")
|
||||
path = self._get_profile_hashes_path()
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
|
||||
@@ -437,6 +451,6 @@ class RemoteLoader(Loader):
|
||||
def _write_profile_hashes(self, hashes: dict[str, str]) -> None:
|
||||
"""Write profile hashes to local storage"""
|
||||
|
||||
path = self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, ".profile_hashes")
|
||||
path = self._get_profile_hashes_path()
|
||||
with open(path, "w") as json_file:
|
||||
json.dump(hashes, json_file, indent=4)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
@@ -131,7 +132,8 @@ class PowerProfile:
|
||||
self._model = model.replace("#slash#", "/")
|
||||
self._hass = hass
|
||||
self._directory = directory
|
||||
self._json_data = json_data
|
||||
self._base_json_data = deepcopy(json_data)
|
||||
self._json_data = deepcopy(json_data)
|
||||
self.sub_profile: str | None = None
|
||||
self._sub_profile_dir: str | None = None
|
||||
self._sub_profiles = sub_profiles or []
|
||||
@@ -411,7 +413,8 @@ class PowerProfile:
|
||||
self._sub_profile_dir = os.path.join(self._directory, sub_profile)
|
||||
_LOGGER.debug("Loading sub profile: %s", sub_profile)
|
||||
|
||||
self._json_data.update(found_profile)
|
||||
self._json_data = deepcopy(self._base_json_data)
|
||||
self._json_data.update(deepcopy(found_profile))
|
||||
|
||||
self.sub_profile = sub_profile
|
||||
|
||||
|
||||
@@ -3,24 +3,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import copy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
import uuid
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, SensorEntity
|
||||
from homeassistant.components.utility_meter import max_28_days
|
||||
from homeassistant.components.utility_meter.const import METER_TYPES
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity
|
||||
from homeassistant.components.utility_meter.sensor import UtilityMeterSensor
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_ENTITIES,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PATH,
|
||||
CONF_UNIQUE_ID,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, SupportsResponse, callback
|
||||
@@ -35,7 +29,6 @@ from homeassistant.helpers.entity_registry import (
|
||||
RegistryEntryDisabler,
|
||||
)
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -46,69 +39,22 @@ from .common import (
|
||||
create_source_entity,
|
||||
get_merged_sensor_configuration,
|
||||
validate_is_number,
|
||||
validate_name_pattern,
|
||||
)
|
||||
from .configuration.config_entry_conversion import convert_config_entry_to_sensor_config
|
||||
from .configuration.discovery_info import convert_discovery_info_to_sensor_config
|
||||
from .configuration.sensor_config import PLATFORM_SCHEMA as PLATFORM_SCHEMA
|
||||
from .const import (
|
||||
CONF_AND,
|
||||
CONF_AVAILABILITY_ENTITY,
|
||||
CONF_CALCULATION_ENABLED_CONDITION,
|
||||
CONF_COMPOSITE,
|
||||
CONF_COST,
|
||||
CONF_CREATE_ENERGY_SENSOR,
|
||||
CONF_CREATE_GROUP,
|
||||
CONF_CREATE_UTILITY_METERS,
|
||||
CONF_CUSTOM_MODEL_DIRECTORY,
|
||||
CONF_DAILY_FIXED_ENERGY,
|
||||
CONF_DELAY,
|
||||
CONF_DISABLE_STANDBY_POWER,
|
||||
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
|
||||
CONF_ENERGY_FILTER_OUTLIER_MAX,
|
||||
CONF_ENERGY_INTEGRATION_METHOD,
|
||||
CONF_ENERGY_SENSOR_CATEGORY,
|
||||
CONF_ENERGY_SENSOR_ID,
|
||||
CONF_ENERGY_SENSOR_NAMING,
|
||||
CONF_ENERGY_SENSOR_UNIT_PREFIX,
|
||||
CONF_FILTER,
|
||||
CONF_FIXED,
|
||||
CONF_FORCE_CALCULATE_GROUP_ENERGY,
|
||||
CONF_FORCE_ENERGY_SENSOR_CREATION,
|
||||
CONF_GROUP_ENERGY_START_AT_ZERO,
|
||||
CONF_GROUP_TYPE,
|
||||
CONF_HIDE_MEMBERS,
|
||||
CONF_IGNORE_UNAVAILABLE_STATE,
|
||||
CONF_INCLUDE,
|
||||
CONF_INCLUDE_NON_POWERCALC_SENSORS,
|
||||
CONF_LINEAR,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MODE,
|
||||
CONF_MODEL,
|
||||
CONF_MULTI_SWITCH,
|
||||
CONF_MULTIPLY_FACTOR,
|
||||
CONF_MULTIPLY_FACTOR_STANDBY,
|
||||
CONF_NOT,
|
||||
CONF_ON_TIME,
|
||||
CONF_OR,
|
||||
CONF_PLAYBOOK,
|
||||
CONF_PLAYBOOKS,
|
||||
CONF_POWER,
|
||||
CONF_POWER_SENSOR_CATEGORY,
|
||||
CONF_POWER_SENSOR_ID,
|
||||
CONF_POWER_SENSOR_NAMING,
|
||||
CONF_POWER_TEMPLATE,
|
||||
CONF_SENSOR_TYPE,
|
||||
CONF_SLEEP_POWER,
|
||||
CONF_STANDBY_POWER,
|
||||
CONF_STATE,
|
||||
CONF_STATES_POWER,
|
||||
CONF_SUBTRACT_ENTITIES,
|
||||
CONF_UNAVAILABLE_POWER,
|
||||
CONF_UTILITY_METER_NET_CONSUMPTION,
|
||||
CONF_UTILITY_METER_OFFSET,
|
||||
CONF_UTILITY_METER_TARIFFS,
|
||||
CONF_UTILITY_METER_TYPES,
|
||||
CONF_VALUE,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
CONF_VARIABLES,
|
||||
CONF_WLED,
|
||||
DATA_CONFIG_TYPES,
|
||||
DATA_CONFIGURED_ENTITIES,
|
||||
DATA_DOMAIN_ENTITIES,
|
||||
@@ -122,157 +68,49 @@ from .const import (
|
||||
DOMAIN,
|
||||
DOMAIN_CONFIG,
|
||||
DUMMY_ENTITY_ID,
|
||||
ENERGY_INTEGRATION_METHODS,
|
||||
ENTITY_CATEGORIES,
|
||||
ENTRY_DATA_ENERGY_ENTITY,
|
||||
ENTRY_DATA_POWER_ENTITY,
|
||||
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
|
||||
SERVICE_ACTIVATE_PLAYBOOK,
|
||||
SERVICE_CALIBRATE_COST,
|
||||
SERVICE_CALIBRATE_ENERGY,
|
||||
SERVICE_CALIBRATE_UTILITY_METER,
|
||||
SERVICE_DEBUG_GROUP,
|
||||
SERVICE_GET_ACTIVE_PLAYBOOK,
|
||||
SERVICE_GET_GROUP_ENTITIES,
|
||||
SERVICE_INCREASE_DAILY_ENERGY,
|
||||
SERVICE_RESET_COST,
|
||||
SERVICE_RESET_ENERGY,
|
||||
SERVICE_STOP_PLAYBOOK,
|
||||
SERVICE_SWITCH_SUB_PROFILE,
|
||||
CalculationStrategy,
|
||||
EntityType,
|
||||
GroupType,
|
||||
PowercalcDiscoveryType,
|
||||
SensorType,
|
||||
UnitPrefix,
|
||||
)
|
||||
from .device_binding import attach_entities_to_source_device
|
||||
from .device_binding import attach_entities_to_resolved_device
|
||||
from .errors import (
|
||||
PowercalcSetupError,
|
||||
SensorAlreadyConfiguredError,
|
||||
SensorConfigurationError,
|
||||
)
|
||||
from .group_include.filter import FILTER_CONFIG, FilterOperator, create_composite_filter
|
||||
from .group_include.filter import FilterOperator, create_composite_filter
|
||||
from .group_include.include import find_entities
|
||||
from .sensors.cost import CostSensor, create_cost_sensor_for_energy_entity
|
||||
from .sensors.daily_energy import (
|
||||
DAILY_FIXED_ENERGY_SCHEMA,
|
||||
create_daily_fixed_energy_power_sensor,
|
||||
create_daily_fixed_energy_sensor,
|
||||
)
|
||||
from .sensors.energy import EnergySensor, create_energy_sensor
|
||||
from .sensors.energy_related import create_energy_related_sensors
|
||||
from .sensors.group.config_entry_utils import add_to_associated_groups
|
||||
from .sensors.group.custom import GroupedSensor
|
||||
from .sensors.group.factory import create_group_sensors
|
||||
from .sensors.group.standby import StandbyPowerSensor
|
||||
from .sensors.power import PowerSensor, VirtualPowerSensor, create_power_sensor
|
||||
from .sensors.utility_meter import create_utility_meters
|
||||
from .strategy.composite import CONFIG_SCHEMA as COMPOSITE_SCHEMA
|
||||
from .strategy.fixed import CONFIG_SCHEMA as FIXED_SCHEMA
|
||||
from .strategy.linear import CONFIG_SCHEMA as LINEAR_SCHEMA
|
||||
from .strategy.multi_switch import CONFIG_SCHEMA as MULTI_SWITCH_SCHEMA
|
||||
from .strategy.playbook import CONFIG_SCHEMA as PLAYBOOK_SCHEMA
|
||||
from .strategy.wled import CONFIG_SCHEMA as WLED_SCHEMA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MAX_GROUP_NESTING_LEVEL = 5
|
||||
|
||||
SENSOR_CONFIG = {
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_AVAILABILITY_ENTITY): cv.entity_id,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
vol.Optional(CONF_MODEL): cv.string,
|
||||
vol.Optional(CONF_MANUFACTURER): cv.string,
|
||||
vol.Optional(CONF_MODE): vol.In([cls.value for cls in CalculationStrategy]),
|
||||
vol.Optional(CONF_STANDBY_POWER): vol.Any(vol.Coerce(float), cv.template),
|
||||
vol.Optional(CONF_DISABLE_STANDBY_POWER): cv.boolean,
|
||||
vol.Optional(CONF_CUSTOM_MODEL_DIRECTORY): cv.string,
|
||||
vol.Optional(CONF_POWER_SENSOR_ID): cv.entity_id,
|
||||
vol.Optional(CONF_FORCE_ENERGY_SENSOR_CREATION): cv.boolean,
|
||||
vol.Optional(CONF_FORCE_CALCULATE_GROUP_ENERGY): cv.boolean,
|
||||
vol.Optional(CONF_FIXED): FIXED_SCHEMA,
|
||||
vol.Optional(CONF_LINEAR): LINEAR_SCHEMA,
|
||||
vol.Optional(CONF_MULTI_SWITCH): MULTI_SWITCH_SCHEMA,
|
||||
vol.Optional(CONF_WLED): WLED_SCHEMA,
|
||||
vol.Optional(CONF_PLAYBOOK): PLAYBOOK_SCHEMA,
|
||||
vol.Optional(CONF_DAILY_FIXED_ENERGY): DAILY_FIXED_ENERGY_SCHEMA,
|
||||
vol.Optional(CONF_CREATE_ENERGY_SENSOR): cv.boolean,
|
||||
vol.Optional(CONF_CREATE_UTILITY_METERS): cv.boolean,
|
||||
vol.Optional(CONF_UTILITY_METER_NET_CONSUMPTION): cv.boolean,
|
||||
vol.Optional(CONF_UTILITY_METER_TARIFFS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_UTILITY_METER_TYPES): vol.All(cv.ensure_list, [vol.In(METER_TYPES)]),
|
||||
vol.Optional(CONF_UTILITY_METER_OFFSET): vol.All(cv.time_period, cv.positive_timedelta, max_28_days),
|
||||
vol.Optional(CONF_MULTIPLY_FACTOR): vol.Coerce(float),
|
||||
vol.Optional(CONF_MULTIPLY_FACTOR_STANDBY): cv.boolean,
|
||||
vol.Optional(CONF_POWER_SENSOR_NAMING): validate_name_pattern,
|
||||
vol.Optional(CONF_POWER_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES),
|
||||
vol.Optional(CONF_ENERGY_SENSOR_ID): cv.entity_id,
|
||||
vol.Optional(CONF_ENERGY_SENSOR_NAMING): validate_name_pattern,
|
||||
vol.Optional(CONF_ENERGY_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES),
|
||||
vol.Optional(CONF_ENERGY_INTEGRATION_METHOD): vol.In(ENERGY_INTEGRATION_METHODS),
|
||||
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_ENABLED): cv.boolean,
|
||||
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_MAX): cv.positive_int,
|
||||
vol.Optional(CONF_ENERGY_SENSOR_UNIT_PREFIX): vol.In([cls.value for cls in UnitPrefix]),
|
||||
vol.Optional(CONF_CREATE_GROUP): cv.string,
|
||||
vol.Optional(CONF_GROUP_ENERGY_START_AT_ZERO): cv.boolean,
|
||||
vol.Optional(CONF_GROUP_TYPE): vol.In([cls.value for cls in GroupType]),
|
||||
vol.Optional(CONF_SUBTRACT_ENTITIES): vol.All(cv.ensure_list, [cv.entity_id]),
|
||||
vol.Optional(CONF_HIDE_MEMBERS): cv.boolean,
|
||||
vol.Optional(CONF_INCLUDE): vol.Schema(
|
||||
{
|
||||
**FILTER_CONFIG.schema,
|
||||
vol.Optional(CONF_FILTER): vol.Schema(
|
||||
{
|
||||
**FILTER_CONFIG.schema,
|
||||
vol.Optional(CONF_OR): vol.All(cv.ensure_list, [FILTER_CONFIG]),
|
||||
vol.Optional(CONF_AND): vol.All(cv.ensure_list, [FILTER_CONFIG]),
|
||||
vol.Optional(CONF_NOT): vol.All(cv.ensure_list, [FILTER_CONFIG]),
|
||||
},
|
||||
),
|
||||
vol.Optional(CONF_INCLUDE_NON_POWERCALC_SENSORS, default=True): cv.boolean,
|
||||
},
|
||||
),
|
||||
vol.Optional(CONF_IGNORE_UNAVAILABLE_STATE): cv.boolean,
|
||||
vol.Optional(CONF_CALCULATION_ENABLED_CONDITION): cv.template,
|
||||
vol.Optional(CONF_SLEEP_POWER): vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_POWER): vol.Coerce(float),
|
||||
vol.Required(CONF_DELAY): cv.positive_int,
|
||||
},
|
||||
),
|
||||
vol.Optional(CONF_UNAVAILABLE_POWER): vol.Coerce(float),
|
||||
vol.Optional(CONF_COMPOSITE): COMPOSITE_SCHEMA,
|
||||
vol.Optional(CONF_VARIABLES): vol.Schema({cv.string: cv.string}),
|
||||
}
|
||||
|
||||
|
||||
def build_nested_configuration_schema(schema: dict, iteration: int = 0) -> dict:
|
||||
if iteration == MAX_GROUP_NESTING_LEVEL:
|
||||
return schema
|
||||
iteration += 1
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(CONF_ENTITIES): vol.All(
|
||||
cv.ensure_list,
|
||||
[build_nested_configuration_schema(schema.copy(), iteration)],
|
||||
),
|
||||
},
|
||||
)
|
||||
return schema
|
||||
|
||||
|
||||
SENSOR_CONFIG = build_nested_configuration_schema(SENSOR_CONFIG)
|
||||
|
||||
PLATFORM_SCHEMA = vol.All(
|
||||
cv.has_at_least_one_key(
|
||||
CONF_ENTITY_ID,
|
||||
CONF_POWER_SENSOR_ID,
|
||||
CONF_ENTITIES,
|
||||
CONF_INCLUDE,
|
||||
CONF_DAILY_FIXED_ENERGY,
|
||||
),
|
||||
PLATFORM_SCHEMA.extend(SENSOR_CONFIG),
|
||||
)
|
||||
|
||||
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
|
||||
|
||||
|
||||
@@ -375,7 +213,7 @@ async def _async_setup_entities(
|
||||
_LOGGER.error(err)
|
||||
return
|
||||
|
||||
await attach_entities_to_source_device(config_entry, entities.new, hass, None)
|
||||
await attach_entities_to_resolved_device(config_entry, entities.new, hass, None, config)
|
||||
|
||||
entities_to_add = [entity for entity in entities.new if isinstance(entity, SensorEntity)]
|
||||
for entity in entities_to_add:
|
||||
@@ -388,13 +226,17 @@ async def _async_setup_entities(
|
||||
# See: https://github.com/bramstroker/homeassistant-powercalc/issues/1454
|
||||
# Remove entities which are disabled because of a disabled device from the list of entities to add
|
||||
# When we add nevertheless the entity_platform code will set device_id to None and abort entity addition.
|
||||
# `async_added_to_hass` hook will not be called, which powercalc uses to bind the entity to device again
|
||||
# This causes the powercalc entity to never be bound to the device again and be disabled forever.
|
||||
# `async_added_to_hass` will not be called, so BaseEntity cannot repair registry metadata.
|
||||
# This causes the powercalc entity to never be rebound and to stay disabled.
|
||||
entity_reg = er.async_get(hass)
|
||||
for entity in entities_to_add:
|
||||
existing_entry = entity_reg.async_get(entity.entity_id)
|
||||
if existing_entry and existing_entry.disabled_by == RegistryEntryDisabler.DEVICE:
|
||||
entities_to_add.remove(entity)
|
||||
entities_to_add = [
|
||||
entity
|
||||
for entity in entities_to_add
|
||||
if not (
|
||||
(existing_entry := entity_reg.async_get(entity.entity_id))
|
||||
and existing_entry.disabled_by == RegistryEntryDisabler.DEVICE
|
||||
)
|
||||
]
|
||||
|
||||
async_add_entities(entities_to_add)
|
||||
|
||||
@@ -447,6 +289,8 @@ def _register_entity_id_change_listener(
|
||||
def _resolve_entity_type(entity: Entity) -> EntityType:
|
||||
if isinstance(entity, UtilityMeterSensor):
|
||||
return EntityType.UTILITY_METER
|
||||
if isinstance(entity, CostSensor):
|
||||
return EntityType.COST_SENSOR
|
||||
if isinstance(entity, EnergySensor):
|
||||
return EntityType.ENERGY_SENSOR
|
||||
if isinstance(entity, PowerSensor):
|
||||
@@ -464,6 +308,9 @@ def save_entity_ids_on_config_entry(
|
||||
We need this in group sensor logic to differentiate between energy sensor and utility meters.
|
||||
"""
|
||||
_LOGGER.debug("Saving entity ids on config entry %s", config_entry.entry_id)
|
||||
if config_entry.data.get(CONF_SENSOR_TYPE) == SensorType.COST:
|
||||
# A standalone cost sensor entry has neither a power nor an energy sensor to track.
|
||||
return
|
||||
power_entities = [e.entity_id for e in entities.all() if isinstance(e, VirtualPowerSensor)]
|
||||
new_data = config_entry.data.copy()
|
||||
if power_entities:
|
||||
@@ -498,12 +345,24 @@ def register_entity_services() -> None:
|
||||
"async_reset",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_RESET_COST,
|
||||
{},
|
||||
"async_reset",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CALIBRATE_UTILITY_METER,
|
||||
{vol.Required(CONF_VALUE): validate_is_number},
|
||||
"async_calibrate",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CALIBRATE_COST,
|
||||
{vol.Required(CONF_VALUE): validate_is_number},
|
||||
"async_calibrate",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CALIBRATE_ENERGY,
|
||||
{vol.Required(CONF_VALUE): validate_is_number},
|
||||
@@ -556,127 +415,6 @@ def register_entity_services() -> None:
|
||||
)
|
||||
|
||||
|
||||
def convert_config_entry_to_sensor_config(config_entry: ConfigEntry, hass: HomeAssistant) -> ConfigType: # noqa: C901
|
||||
"""Convert the config entry structure to the sensor config used to create the entities."""
|
||||
sensor_config = dict(config_entry.data.copy())
|
||||
sensor_type = sensor_config.get(CONF_SENSOR_TYPE)
|
||||
|
||||
def handle_sensor_type() -> None:
|
||||
"""Handle sensor type-specific configuration."""
|
||||
if sensor_type == SensorType.GROUP:
|
||||
sensor_config[CONF_CREATE_GROUP] = sensor_config.get(CONF_NAME)
|
||||
elif sensor_type == SensorType.REAL_POWER:
|
||||
sensor_config[CONF_POWER_SENSOR_ID] = sensor_config.get(CONF_ENTITY_ID)
|
||||
sensor_config[CONF_FORCE_ENERGY_SENSOR_CREATION] = True
|
||||
|
||||
def process_template(config: dict, template_key: str, target_key: str) -> None:
|
||||
"""Convert a template key in the config to a Template object."""
|
||||
if template_key in config:
|
||||
config[target_key] = Template(config[template_key], hass)
|
||||
del config[template_key]
|
||||
|
||||
def process_on_time(config: dict) -> None:
|
||||
"""Convert on_time dictionary to timedelta."""
|
||||
on_time = config.get(CONF_ON_TIME)
|
||||
config[CONF_ON_TIME] = (
|
||||
timedelta(hours=on_time["hours"], minutes=on_time["minutes"], seconds=on_time["seconds"])
|
||||
if on_time
|
||||
else timedelta(days=1)
|
||||
)
|
||||
|
||||
def process_states_power(states_power: dict | list) -> dict:
|
||||
"""Convert state power values to Template objects where necessary.
|
||||
|
||||
Handles both dict format (legacy/YAML) and list format (config flow).
|
||||
"""
|
||||
if isinstance(states_power, list):
|
||||
states_power = {item[CONF_STATE]: item[CONF_POWER] for item in states_power}
|
||||
return {
|
||||
key: Template(value, hass) if isinstance(value, str) and "{{" in value else value
|
||||
for key, value in states_power.items()
|
||||
}
|
||||
|
||||
def process_daily_fixed_energy() -> None:
|
||||
"""Process daily fixed energy configuration."""
|
||||
if CONF_DAILY_FIXED_ENERGY not in sensor_config:
|
||||
return
|
||||
|
||||
daily_fixed_config = copy.copy(sensor_config[CONF_DAILY_FIXED_ENERGY])
|
||||
process_template(daily_fixed_config, CONF_VALUE_TEMPLATE, CONF_VALUE)
|
||||
process_on_time(daily_fixed_config)
|
||||
sensor_config[CONF_DAILY_FIXED_ENERGY] = daily_fixed_config
|
||||
|
||||
def process_fixed_config() -> None:
|
||||
"""Process fixed energy configuration."""
|
||||
if CONF_FIXED not in sensor_config:
|
||||
return
|
||||
|
||||
fixed_config = copy.copy(sensor_config[CONF_FIXED])
|
||||
process_template(fixed_config, CONF_POWER_TEMPLATE, CONF_POWER)
|
||||
if CONF_STATES_POWER in fixed_config:
|
||||
fixed_config[CONF_STATES_POWER] = process_states_power(fixed_config[CONF_STATES_POWER])
|
||||
sensor_config[CONF_FIXED] = fixed_config
|
||||
|
||||
def process_linear_config() -> None:
|
||||
"""Process linear energy configuration."""
|
||||
if CONF_LINEAR not in sensor_config:
|
||||
return
|
||||
|
||||
linear_config = copy.copy(sensor_config[CONF_LINEAR])
|
||||
sensor_config[CONF_LINEAR] = linear_config
|
||||
|
||||
def process_calculation_enabled_condition() -> None:
|
||||
"""Process calculation enabled condition template."""
|
||||
if CONF_CALCULATION_ENABLED_CONDITION in sensor_config:
|
||||
sensor_config[CONF_CALCULATION_ENABLED_CONDITION] = Template(
|
||||
sensor_config[CONF_CALCULATION_ENABLED_CONDITION],
|
||||
hass,
|
||||
)
|
||||
|
||||
def process_utility_meter_offset() -> None:
|
||||
if CONF_UTILITY_METER_OFFSET in sensor_config:
|
||||
sensor_config[CONF_UTILITY_METER_OFFSET] = timedelta(days=sensor_config[CONF_UTILITY_METER_OFFSET])
|
||||
|
||||
def process_playbook_config() -> None:
|
||||
if CONF_PLAYBOOK not in sensor_config:
|
||||
return
|
||||
playbook_config = copy.copy(sensor_config[CONF_PLAYBOOK])
|
||||
playbook_config[CONF_PLAYBOOKS] = {item[CONF_ID]: item[CONF_PATH] for item in playbook_config[CONF_PLAYBOOKS]}
|
||||
sensor_config[CONF_PLAYBOOK] = playbook_config
|
||||
|
||||
handle_sensor_type()
|
||||
|
||||
process_daily_fixed_energy()
|
||||
process_fixed_config()
|
||||
process_linear_config()
|
||||
process_playbook_config()
|
||||
process_calculation_enabled_condition()
|
||||
process_utility_meter_offset()
|
||||
|
||||
return sensor_config
|
||||
|
||||
|
||||
def convert_discovery_info_to_sensor_config(
|
||||
discovery_info: DiscoveryInfoType,
|
||||
) -> ConfigType:
|
||||
"""Convert discovery info to sensor config."""
|
||||
if discovery_info[DISCOVERY_TYPE] == PowercalcDiscoveryType.DOMAIN_GROUP:
|
||||
config = discovery_info
|
||||
config[CONF_GROUP_TYPE] = GroupType.DOMAIN
|
||||
config[CONF_SENSOR_TYPE] = SensorType.GROUP
|
||||
config[CONF_ENTITY_ID] = DUMMY_ENTITY_ID
|
||||
return config
|
||||
|
||||
if discovery_info[DISCOVERY_TYPE] == PowercalcDiscoveryType.STANDBY_GROUP:
|
||||
config = discovery_info
|
||||
config[CONF_GROUP_TYPE] = GroupType.STANDBY
|
||||
config[CONF_SENSOR_TYPE] = SensorType.GROUP
|
||||
config[CONF_ENTITY_ID] = DUMMY_ENTITY_ID
|
||||
return config
|
||||
|
||||
return discovery_info
|
||||
|
||||
|
||||
async def create_sensors(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -720,16 +458,31 @@ async def setup_individual_sensors(
|
||||
context: CreationContext,
|
||||
) -> EntitiesBucket:
|
||||
"""Set up an individual sensor."""
|
||||
if CONF_COST in config:
|
||||
config[CONF_SENSOR_TYPE] = SensorType.COST
|
||||
|
||||
sensor_type = resolve_sensor_type(config)
|
||||
merged_sensor_config = get_merged_sensor_configuration(global_config, config)
|
||||
sensor_type = SensorType(str(config.get(CONF_SENSOR_TYPE, SensorType.VIRTUAL_POWER)))
|
||||
|
||||
if sensor_type == SensorType.GROUP:
|
||||
collect_sensor_analytics(hass, sensor_type, context.discovery_type, config_entry)
|
||||
return EntitiesBucket(new=await create_group_sensors(hass, merged_sensor_config, config_entry))
|
||||
|
||||
if sensor_type == SensorType.COST:
|
||||
collect_sensor_analytics(hass, sensor_type, context.discovery_type, config_entry)
|
||||
cost_sensor = create_cost_sensor_for_energy_entity(hass, merged_sensor_config)
|
||||
return EntitiesBucket(new=[cost_sensor] if cost_sensor else [])
|
||||
|
||||
return await create_individual_sensors(hass, merged_sensor_config, context, sensor_type, config_entry)
|
||||
|
||||
|
||||
def resolve_sensor_type(config: ConfigType) -> SensorType:
|
||||
"""Resolve the sensor type based on the configuration."""
|
||||
if CONF_COST in config:
|
||||
return SensorType.COST
|
||||
return SensorType(str(config.get(CONF_SENSOR_TYPE, SensorType.VIRTUAL_POWER)))
|
||||
|
||||
|
||||
def collect_sensor_analytics(
|
||||
hass: HomeAssistant,
|
||||
sensor_type: SensorType,
|
||||
@@ -918,9 +671,11 @@ async def create_individual_sensors(
|
||||
attach_energy_sensor_to_power_sensor(power_sensor, energy_sensor)
|
||||
|
||||
if energy_sensor:
|
||||
entities_to_add.extend(create_utility_meters(hass, energy_sensor, sensor_config, config_entry))
|
||||
entities_to_add.extend(
|
||||
create_energy_related_sensors(hass, sensor_config, energy_sensor, source_entity, config_entry),
|
||||
)
|
||||
|
||||
await attach_entities_to_source_device(config_entry, entities_to_add, hass, source_entity)
|
||||
await attach_entities_to_resolved_device(config_entry, entities_to_add, hass, source_entity, sensor_config)
|
||||
update_registries(hass, source_entity, entities_to_add, context)
|
||||
|
||||
unique_id = sensor_config.get(CONF_UNIQUE_ID) or source_entity.unique_id
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,25 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.entity import Entity, async_generate_entity_id
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_COST_SENSOR_FRIENDLY_NAMING,
|
||||
CONF_COST_SENSOR_NAMING,
|
||||
CONF_ENERGY_SENSOR_FRIENDLY_NAMING,
|
||||
CONF_ENERGY_SENSOR_NAMING,
|
||||
CONF_POWER_SENSOR_FRIENDLY_NAMING,
|
||||
CONF_POWER_SENSOR_NAMING,
|
||||
DEFAULT_COST_NAME_PATTERN,
|
||||
DEFAULT_ENERGY_NAME_PATTERN,
|
||||
DEFAULT_POWER_NAME_PATTERN,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.powercalc.device_binding import bind_entity_to_registry_metadata
|
||||
|
||||
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
|
||||
|
||||
@@ -28,20 +33,15 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
class BaseEntity(Entity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Attach the entity to same device as the source entity."""
|
||||
"""Bind configured registry metadata."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
entity_reg = er.async_get(self.hass)
|
||||
entity_entry = entity_reg.async_get(self.entity_id)
|
||||
if entity_entry is None or not hasattr(self, "source_device_id"):
|
||||
return
|
||||
|
||||
device_id: str = getattr(self, "source_device_id") # noqa: B009
|
||||
device_reg = dr.async_get(self.hass)
|
||||
device_entry = device_reg.async_get(device_id)
|
||||
if not device_entry or device_entry.id == entity_entry.device_id: # pragma: no cover
|
||||
return
|
||||
_LOGGER.debug("Binding %s to device %s", self.entity_id, device_id)
|
||||
entity_reg.async_update_entity(self.entity_id, device_id=device_id)
|
||||
bind_entity_to_registry_metadata(
|
||||
self.hass,
|
||||
self.entity_id,
|
||||
cast(DeviceEntry | None, getattr(self, "device_entry", None)),
|
||||
cast(ConfigType | None, getattr(self, "_sensor_config", None)),
|
||||
)
|
||||
|
||||
|
||||
def generate_power_sensor_name(
|
||||
@@ -54,6 +54,7 @@ def generate_power_sensor_name(
|
||||
sensor_config,
|
||||
CONF_POWER_SENSOR_NAMING,
|
||||
CONF_POWER_SENSOR_FRIENDLY_NAMING,
|
||||
DEFAULT_POWER_NAME_PATTERN,
|
||||
name,
|
||||
source_entity,
|
||||
)
|
||||
@@ -69,6 +70,23 @@ def generate_energy_sensor_name(
|
||||
sensor_config,
|
||||
CONF_ENERGY_SENSOR_NAMING,
|
||||
CONF_ENERGY_SENSOR_FRIENDLY_NAMING,
|
||||
DEFAULT_ENERGY_NAME_PATTERN,
|
||||
name,
|
||||
source_entity,
|
||||
)
|
||||
|
||||
|
||||
def generate_cost_sensor_name(
|
||||
sensor_config: ConfigType,
|
||||
name: str | None = None,
|
||||
source_entity: SourceEntity | None = None,
|
||||
) -> str:
|
||||
"""Generates the name to use for a cost sensor."""
|
||||
return _generate_sensor_name(
|
||||
sensor_config,
|
||||
CONF_COST_SENSOR_NAMING,
|
||||
CONF_COST_SENSOR_FRIENDLY_NAMING,
|
||||
DEFAULT_COST_NAME_PATTERN,
|
||||
name,
|
||||
source_entity,
|
||||
)
|
||||
@@ -78,6 +96,7 @@ def _generate_sensor_name(
|
||||
sensor_config: ConfigType,
|
||||
naming_conf_key: str,
|
||||
friendly_naming_conf_key: str,
|
||||
default_pattern: str,
|
||||
name: str | None = None,
|
||||
source_entity: SourceEntity | None = None,
|
||||
) -> str:
|
||||
@@ -89,12 +108,7 @@ def _generate_sensor_name(
|
||||
friendly_name_pattern = str(sensor_config.get(friendly_naming_conf_key))
|
||||
return friendly_name_pattern.format(name)
|
||||
|
||||
name_pattern = str(
|
||||
sensor_config.get(
|
||||
naming_conf_key,
|
||||
DEFAULT_POWER_NAME_PATTERN if naming_conf_key == CONF_POWER_SENSOR_NAMING else DEFAULT_ENERGY_NAME_PATTERN,
|
||||
),
|
||||
)
|
||||
name_pattern = str(sensor_config.get(naming_conf_key, default_pattern))
|
||||
return name_pattern.format(name)
|
||||
|
||||
|
||||
@@ -107,16 +121,14 @@ def generate_power_sensor_entity_id(
|
||||
unique_id: str | None = None,
|
||||
) -> str:
|
||||
"""Generates the entity_id to use for a power sensor."""
|
||||
if entity_id := get_entity_id_by_unique_id(hass, unique_id):
|
||||
return entity_id
|
||||
name_pattern = str(sensor_config.get(CONF_POWER_SENSOR_NAMING, DEFAULT_POWER_NAME_PATTERN))
|
||||
object_id = name or sensor_config.get(CONF_NAME)
|
||||
if object_id is None and source_entity:
|
||||
object_id = source_entity.object_id
|
||||
return async_generate_entity_id(
|
||||
ENTITY_ID_FORMAT,
|
||||
name_pattern.format(object_id),
|
||||
hass=hass,
|
||||
return _generate_sensor_entity_id(
|
||||
hass,
|
||||
sensor_config,
|
||||
CONF_POWER_SENSOR_NAMING,
|
||||
DEFAULT_POWER_NAME_PATTERN,
|
||||
source_entity,
|
||||
name,
|
||||
unique_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -129,9 +141,50 @@ def generate_energy_sensor_entity_id(
|
||||
unique_id: str | None = None,
|
||||
) -> str:
|
||||
"""Generates the entity_id to use for an energy sensor."""
|
||||
return _generate_sensor_entity_id(
|
||||
hass,
|
||||
sensor_config,
|
||||
CONF_ENERGY_SENSOR_NAMING,
|
||||
DEFAULT_ENERGY_NAME_PATTERN,
|
||||
source_entity,
|
||||
name,
|
||||
unique_id,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def generate_cost_sensor_entity_id(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
source_entity: SourceEntity | None = None,
|
||||
name: str | None = None,
|
||||
unique_id: str | None = None,
|
||||
) -> str:
|
||||
"""Generates the entity_id to use for a cost sensor."""
|
||||
return _generate_sensor_entity_id(
|
||||
hass,
|
||||
sensor_config,
|
||||
CONF_COST_SENSOR_NAMING,
|
||||
DEFAULT_COST_NAME_PATTERN,
|
||||
source_entity,
|
||||
name,
|
||||
unique_id,
|
||||
)
|
||||
|
||||
|
||||
def _generate_sensor_entity_id(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
naming_conf_key: str,
|
||||
default_pattern: str,
|
||||
source_entity: SourceEntity | None = None,
|
||||
name: str | None = None,
|
||||
unique_id: str | None = None,
|
||||
) -> str:
|
||||
"""Generates the entity_id to use for a sensor."""
|
||||
if entity_id := get_entity_id_by_unique_id(hass, unique_id):
|
||||
return entity_id
|
||||
name_pattern = str(sensor_config.get(CONF_ENERGY_SENSOR_NAMING, DEFAULT_ENERGY_NAME_PATTERN))
|
||||
name_pattern = str(sensor_config.get(naming_conf_key, default_pattern))
|
||||
object_id = name or sensor_config.get(CONF_NAME)
|
||||
if object_id is None and source_entity:
|
||||
object_id = source_entity.object_id
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.sensor import ATTR_LAST_RESET, SensorDeviceClass, SensorEntity, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONF_NAME,
|
||||
CONF_UNIQUE_ID,
|
||||
UnitOfEnergy,
|
||||
)
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State, callback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.const import (
|
||||
CONF_COST,
|
||||
CONF_COST_SENSOR_PRECISION,
|
||||
CONF_ENERGY_PRICE,
|
||||
CONF_ENERGY_PRICE_MULTIPLIER,
|
||||
CONF_ENERGY_PRICE_SENSOR,
|
||||
CONF_ENERGY_PRICE_SURCHARGE,
|
||||
CONF_ENERGY_SENSOR_ID,
|
||||
DEFAULT_COST_SENSOR_PRECISION,
|
||||
DOMAIN,
|
||||
DOMAIN_CONFIG,
|
||||
)
|
||||
from custom_components.powercalc.unit import convert_to_decimal, parse_decimal
|
||||
|
||||
from .abstract import (
|
||||
BaseEntity,
|
||||
generate_cost_sensor_entity_id,
|
||||
generate_cost_sensor_name,
|
||||
)
|
||||
from .energy import EnergySensor, resolve_existing_energy_sensor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from .utility_meter import VirtualUtilityMeter
|
||||
|
||||
COST_ICON = "mdi:cash"
|
||||
ATTR_LAST_ENERGY = "last_energy"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_scaled(state: State | None, unit_to_factor: Callable[[str | None], Decimal]) -> Decimal | None:
|
||||
"""Parse a numeric state into a Decimal, scaled by a factor derived from its unit.
|
||||
|
||||
Both energy amounts (converted to kWh) and prices (converted to per kWh) are parsed this
|
||||
way; ``unit_to_factor`` maps the state's unit of measurement to the multiplier to apply.
|
||||
"""
|
||||
value = parse_decimal(state)
|
||||
if value is None:
|
||||
return None
|
||||
assert state is not None # a parsed value implies the state is present and numeric
|
||||
return value * unit_to_factor(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
|
||||
|
||||
|
||||
def _to_kwh_factor(unit: str | None) -> Decimal:
|
||||
"""Return the multiplier to convert an energy value expressed in `unit` to kWh.
|
||||
|
||||
Falls back to 1 (assume kWh) when the unit is missing or not a recognizable energy unit.
|
||||
"""
|
||||
if not unit or unit == UnitOfEnergy.KILO_WATT_HOUR:
|
||||
return Decimal(1)
|
||||
if (factor := convert_to_decimal(1, unit, UnitOfEnergy.KILO_WATT_HOUR)) is not None:
|
||||
return factor
|
||||
_LOGGER.warning("Cannot convert energy unit '%s' to kWh, assuming kWh", unit)
|
||||
return Decimal(1)
|
||||
|
||||
|
||||
def _price_per_kwh_factor(unit: str | None) -> Decimal:
|
||||
"""Return the multiplier that converts a price value to a price per kWh.
|
||||
|
||||
Price sensors express their unit as ``<currency>/<energy>`` (for example ``EUR/MWh``).
|
||||
The energy denominator determines how the raw value maps to a per-kWh price, e.g.
|
||||
``EUR/MWh`` -> value * 0.001 and ``EUR/Wh`` -> value * 1000. Falls back to 1 (assume the
|
||||
value is already per kWh) when there is no denominator or it is not a recognizable unit.
|
||||
"""
|
||||
if not unit or "/" not in unit:
|
||||
return Decimal(1)
|
||||
denominator = unit.rsplit("/", 1)[-1].strip()
|
||||
if not denominator or denominator == UnitOfEnergy.KILO_WATT_HOUR:
|
||||
return Decimal(1)
|
||||
if (factor := convert_to_decimal(1, UnitOfEnergy.KILO_WATT_HOUR, denominator)) is not None:
|
||||
return factor
|
||||
_LOGGER.warning("Cannot convert energy price unit '%s' to a per-kWh price, assuming per kWh", unit)
|
||||
return Decimal(1)
|
||||
|
||||
|
||||
def _currency_from_price_unit(unit: str | None) -> str | None:
|
||||
"""Derive the monetary unit from an energy price sensor's unit of measurement.
|
||||
|
||||
Price sensors typically express their unit as ``<currency>/kWh`` (for example
|
||||
``EUR/kWh`` or ``€/kWh``). The part before the slash is the currency the cost is
|
||||
denominated in. Returns None when no currency can be determined.
|
||||
"""
|
||||
if not unit:
|
||||
return None
|
||||
currency = unit.split("/", 1)[0].strip()
|
||||
return currency or None
|
||||
|
||||
|
||||
def create_cost_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
energy_sensor: EnergySensor | VirtualUtilityMeter,
|
||||
source_entity: SourceEntity | None = None,
|
||||
name: str | None = None,
|
||||
reset_on_source_reset: bool = False,
|
||||
unique_id: str | None = None,
|
||||
) -> CostSensor | None:
|
||||
"""Create a cost sensor tracking the cost of the given energy sensor.
|
||||
|
||||
The energy sensor can be a regular energy sensor or a utility meter. The energy
|
||||
price is defined globally, either as a fixed price or a price sensor. When no price
|
||||
is configured, no cost sensor is created. A ``unique_id`` can be provided to override
|
||||
the id derived from the energy sensor (used for standalone cost sensor config entries).
|
||||
"""
|
||||
global_config: ConfigType = hass.data[DOMAIN].get(DOMAIN_CONFIG, {})
|
||||
fixed_price = global_config.get(CONF_ENERGY_PRICE)
|
||||
price_entity_id = global_config.get(CONF_ENERGY_PRICE_SENSOR)
|
||||
price_surcharge = Decimal(str(global_config.get(CONF_ENERGY_PRICE_SURCHARGE, 0) or 0))
|
||||
price_multiplier = Decimal(str(global_config.get(CONF_ENERGY_PRICE_MULTIPLIER, 1) or 1))
|
||||
|
||||
if fixed_price is None and not price_entity_id:
|
||||
_LOGGER.warning(
|
||||
"Cost sensor creation is enabled but no energy price is configured. "
|
||||
"Define `energy_price` or `energy_price_sensor` in the global powercalc configuration",
|
||||
)
|
||||
return None
|
||||
|
||||
name_base = name if name is not None else sensor_config.get(CONF_NAME)
|
||||
cost_name = generate_cost_sensor_name(sensor_config, name_base, source_entity)
|
||||
if unique_id is None and energy_sensor.unique_id is not None:
|
||||
unique_id = f"{energy_sensor.unique_id}_cost"
|
||||
entity_id = generate_cost_sensor_entity_id(
|
||||
hass,
|
||||
sensor_config,
|
||||
source_entity,
|
||||
name=name_base,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
(
|
||||
"Creating cost sensor (entity_id=%s, source_entity=%s, fixed_price=%s, "
|
||||
"price_entity=%s, price_surcharge=%s, price_multiplier=%s)"
|
||||
),
|
||||
entity_id,
|
||||
energy_sensor.entity_id,
|
||||
fixed_price,
|
||||
price_entity_id,
|
||||
price_surcharge,
|
||||
price_multiplier,
|
||||
)
|
||||
|
||||
return CostSensor(
|
||||
hass=hass,
|
||||
source_energy_entity=energy_sensor.entity_id,
|
||||
entity_id=entity_id,
|
||||
unique_id=unique_id,
|
||||
name=cost_name,
|
||||
sensor_config=sensor_config,
|
||||
fixed_price=Decimal(str(fixed_price)) if fixed_price is not None else None,
|
||||
price_entity_id=price_entity_id,
|
||||
price_surcharge=price_surcharge,
|
||||
price_multiplier=price_multiplier,
|
||||
reset_on_source_reset=reset_on_source_reset,
|
||||
)
|
||||
|
||||
|
||||
def create_cost_sensor_for_energy_entity(hass: HomeAssistant, sensor_config: ConfigType) -> CostSensor | None:
|
||||
"""Create a standalone cost sensor tracking an existing (non-powercalc) energy sensor.
|
||||
|
||||
The tracked energy sensor is read from the ``cost`` block (YAML) or the flat
|
||||
``energy_sensor_id`` key (GUI config entry).
|
||||
"""
|
||||
cost_config = sensor_config.get(CONF_COST, {})
|
||||
energy_sensor_id = cost_config.get(CONF_ENERGY_SENSOR_ID) or sensor_config[CONF_ENERGY_SENSOR_ID]
|
||||
energy_sensor = resolve_existing_energy_sensor(hass, energy_sensor_id)
|
||||
# In YAML the name is optional, fall back to the name of the tracked energy sensor.
|
||||
name = sensor_config.get(CONF_NAME) or energy_sensor.name
|
||||
return create_cost_sensor(
|
||||
hass,
|
||||
sensor_config,
|
||||
energy_sensor,
|
||||
name=name,
|
||||
unique_id=sensor_config.get(CONF_UNIQUE_ID),
|
||||
)
|
||||
|
||||
|
||||
class CostSensor(BaseEntity, RestoreEntity, SensorEntity):
|
||||
"""Cost sensor, accumulating the cost of the energy consumed at price-at-consumption."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.MONETARY
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
_attr_should_poll = False
|
||||
_attr_icon = COST_ICON
|
||||
_unrecorded_attributes = frozenset({ATTR_LAST_ENERGY})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
source_energy_entity: str,
|
||||
entity_id: str,
|
||||
sensor_config: ConfigType,
|
||||
name: str | None = None,
|
||||
unique_id: str | None = None,
|
||||
fixed_price: Decimal | None = None,
|
||||
price_entity_id: str | None = None,
|
||||
price_surcharge: Decimal = Decimal(0),
|
||||
price_multiplier: Decimal = Decimal(1),
|
||||
reset_on_source_reset: bool = False,
|
||||
) -> None:
|
||||
self._source_energy_entity = source_energy_entity
|
||||
self._sensor_config = sensor_config
|
||||
self._reset_on_source_reset = reset_on_source_reset
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_native_unit_of_measurement = hass.config.currency
|
||||
self._fixed_price = fixed_price
|
||||
self._price_entity_id = price_entity_id
|
||||
self._price_surcharge = price_surcharge
|
||||
self._price_multiplier = price_multiplier
|
||||
self._rounding_digits = int(sensor_config.get(CONF_COST_SENSOR_PRECISION, DEFAULT_COST_SENSOR_PRECISION))
|
||||
self._attr_suggested_display_precision = self._rounding_digits
|
||||
self._state: Decimal = Decimal(0)
|
||||
self._last_energy: Decimal | None = None
|
||||
self._current_price: Decimal | None = self._effective_price(fixed_price)
|
||||
# Only a resetting (per utility meter cycle) sensor uses last_reset; a lifetime cost
|
||||
# sensor accumulates monotonically and leaves it None.
|
||||
self._attr_last_reset: datetime | None = None
|
||||
self.entity_id = entity_id
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore state and start tracking the source energy and price sensors."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if (state := await self.async_get_last_state()) is not None:
|
||||
self._state = parse_decimal(state) or Decimal(0)
|
||||
self._last_energy = parse_decimal(state.attributes.get(ATTR_LAST_ENERGY))
|
||||
|
||||
# A resetting (per utility meter cycle) cost sensor exposes last_reset so long-term
|
||||
# statistics treat each cycle reset as a new cycle rather than negative consumption.
|
||||
# Restore it across restarts, falling back to now for a freshly created sensor.
|
||||
if self._reset_on_source_reset:
|
||||
restored = dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET, "")) if state else None
|
||||
self._attr_last_reset = restored or dt_util.utcnow()
|
||||
|
||||
_LOGGER.debug("%s: Restoring cost sensor state: %s", self.entity_id, self._state)
|
||||
|
||||
# Seed the current price and, for a price sensor, track its changes so consumption
|
||||
# is always settled at the price that was in effect when it was consumed.
|
||||
if self._price_entity_id is not None:
|
||||
price_state = self.hass.states.get(self._price_entity_id)
|
||||
# Prefer the currency of the price sensor (e.g. `€/kWh` -> `€`) over the HA currency.
|
||||
price_unit = price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if price_state else None
|
||||
if (currency := _currency_from_price_unit(price_unit)) is not None:
|
||||
self._attr_native_unit_of_measurement = currency
|
||||
self._current_price = self._effective_price(_parse_scaled(price_state, _price_per_kwh_factor))
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self._price_entity_id],
|
||||
self._handle_price_state_change,
|
||||
),
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self._source_energy_entity],
|
||||
self._handle_energy_state_change,
|
||||
),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_energy_state_change(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Accumulate cost based on the delta of the energy sensor and the current price."""
|
||||
new_energy = _parse_scaled(event.data["new_state"], _to_kwh_factor)
|
||||
if new_energy is None:
|
||||
return
|
||||
|
||||
# First reading only establishes a baseline to measure deltas from.
|
||||
if self._last_energy is None:
|
||||
self._last_energy = new_energy
|
||||
return
|
||||
|
||||
self._accumulate(new_energy, self._current_price)
|
||||
|
||||
@callback
|
||||
def _handle_price_state_change(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Settle the energy consumed so far at the previous price, then adopt the new price."""
|
||||
# Recalculate the outstanding energy delta with the previously known price before switching.
|
||||
if self._last_energy is not None and self._current_price is not None:
|
||||
current_energy = _parse_scaled(self.hass.states.get(self._source_energy_entity), _to_kwh_factor)
|
||||
if current_energy is not None:
|
||||
self._accumulate(current_energy, self._current_price)
|
||||
|
||||
self._current_price = self._effective_price(_parse_scaled(event.data["new_state"], _price_per_kwh_factor))
|
||||
|
||||
def _effective_price(self, price: Decimal | None) -> Decimal | None:
|
||||
if price is None:
|
||||
return None
|
||||
return (price + self._price_surcharge) * self._price_multiplier
|
||||
|
||||
def _accumulate(self, new_energy: Decimal, price: Decimal | None) -> None:
|
||||
"""Add the cost of the consumed energy at the given price and advance the baseline."""
|
||||
# Leave _last_energy untouched when no price is known, so the consumption is
|
||||
# priced once a price becomes available again.
|
||||
if price is None or self._last_energy is None:
|
||||
return
|
||||
|
||||
delta = new_energy - self._last_energy
|
||||
if delta < 0:
|
||||
if self._reset_on_source_reset:
|
||||
# The source (utility meter) reset for a new cycle, start the cost cycle over.
|
||||
self._state = Decimal(0)
|
||||
self._last_energy = new_energy
|
||||
self._attr_last_reset = dt_util.utcnow()
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
# The energy sensor got reset (e.g. restart or calibrate), treat the new value as the delta.
|
||||
delta = new_energy
|
||||
if delta == 0:
|
||||
return
|
||||
|
||||
self._state += delta * price
|
||||
self._last_energy = new_energy
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def async_reset(self) -> None:
|
||||
"""Reset the cost sensor to zero from the current source energy reading."""
|
||||
_LOGGER.debug("%s: Reset cost sensor", self.entity_id)
|
||||
self._state = Decimal(0)
|
||||
self._attr_last_reset = dt_util.utcnow()
|
||||
self._set_current_energy_baseline()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_calibrate(self, value: str) -> None:
|
||||
"""Set the cost sensor to the given value from the current source energy reading."""
|
||||
_LOGGER.debug("%s: Calibrate cost sensor to: %s", self.entity_id, value)
|
||||
self._state = Decimal(value)
|
||||
self._set_current_energy_baseline()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _set_current_energy_baseline(self) -> None:
|
||||
current_energy = _parse_scaled(self.hass.states.get(self._source_energy_entity), _to_kwh_factor)
|
||||
if current_energy is not None:
|
||||
self._last_energy = current_energy
|
||||
|
||||
@property
|
||||
def native_value(self) -> Decimal:
|
||||
"""Return the accumulated cost."""
|
||||
return Decimal(round(self._state, self._rounding_digits))
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str] | None:
|
||||
"""Return the state attributes of the cost sensor."""
|
||||
if self._last_energy is None:
|
||||
return None
|
||||
return {ATTR_LAST_ENERGY: str(self._last_energy)}
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, time, timedelta
|
||||
import decimal
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -21,13 +20,13 @@ from homeassistant.const import (
|
||||
UnitOfPower,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import TemplateError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.util.unit_conversion import EnergyConverter
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
@@ -45,6 +44,7 @@ from custom_components.powercalc.const import (
|
||||
DEFAULT_ENERGY_SENSOR_PRECISION,
|
||||
UnitPrefix,
|
||||
)
|
||||
from custom_components.powercalc.unit import ENERGY_UNIT_PREFIX_MAPPING, evaluate_to_decimal, parse_decimal
|
||||
|
||||
from .abstract import generate_energy_sensor_entity_id, generate_energy_sensor_name
|
||||
from .energy import EnergySensor
|
||||
@@ -161,7 +161,7 @@ async def create_daily_fixed_energy_power_sensor(
|
||||
)
|
||||
|
||||
|
||||
class DailyEnergySensor(RestoreEntity, SensorEntity, EnergySensor):
|
||||
class DailyEnergySensor(EnergySensor, RestoreEntity, SensorEntity):
|
||||
_attr_device_class = SensorDeviceClass.ENERGY
|
||||
_attr_state_class = SensorStateClass.TOTAL
|
||||
_attr_should_poll = False
|
||||
@@ -202,25 +202,25 @@ class DailyEnergySensor(RestoreEntity, SensorEntity, EnergySensor):
|
||||
def set_native_unit_of_measurement(self) -> None:
|
||||
"""Set the native unit of measurement."""
|
||||
unit_prefix = self._sensor_config.get(CONF_ENERGY_SENSOR_UNIT_PREFIX) or UnitPrefix.KILO
|
||||
if unit_prefix == UnitPrefix.KILO:
|
||||
self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
|
||||
elif unit_prefix == UnitPrefix.NONE:
|
||||
self._attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR
|
||||
elif unit_prefix == UnitPrefix.MEGA:
|
||||
self._attr_native_unit_of_measurement = UnitOfEnergy.MEGA_WATT_HOUR
|
||||
self._attr_native_unit_of_measurement = ENERGY_UNIT_PREFIX_MAPPING.get(
|
||||
unit_prefix,
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if state := await self.async_get_last_state():
|
||||
try:
|
||||
self._state = Decimal(state.state)
|
||||
except decimal.DecimalException:
|
||||
if (restored := parse_decimal(state)) is None:
|
||||
_LOGGER.warning(
|
||||
"%s: Cannot restore state: %s",
|
||||
self.entity_id,
|
||||
state.state,
|
||||
)
|
||||
self._state = Decimal(0)
|
||||
else:
|
||||
self._state = restored
|
||||
self._last_updated = state.last_changed.timestamp()
|
||||
self._state += self.calculate_delta()
|
||||
self.async_schedule_update_ha_state()
|
||||
@@ -258,32 +258,23 @@ class DailyEnergySensor(RestoreEntity, SensorEntity, EnergySensor):
|
||||
elapsed_seconds = (int(self._last_delta_calculate) - int(self._last_updated)) + elapsed_seconds
|
||||
self._last_delta_calculate = dt_util.utcnow().timestamp()
|
||||
|
||||
value = self._value
|
||||
if isinstance(value, Template):
|
||||
value.hass = self.hass
|
||||
try:
|
||||
value = float(value.async_render())
|
||||
except TemplateError as ex:
|
||||
_LOGGER.error(
|
||||
"%s: Could not render value template %s: %s",
|
||||
self.entity_id,
|
||||
value,
|
||||
ex,
|
||||
)
|
||||
return Decimal(0)
|
||||
rendered = evaluate_to_decimal(self._value)
|
||||
if rendered is None:
|
||||
return Decimal(0)
|
||||
value = float(rendered)
|
||||
|
||||
wh_per_day = (
|
||||
value * (self._on_time.total_seconds() / 3600)
|
||||
if self._user_unit_of_measurement == UnitOfPower.WATT
|
||||
else value * 1000
|
||||
else EnergyConverter.convert(value, UnitOfEnergy.KILO_WATT_HOUR, UnitOfEnergy.WATT_HOUR)
|
||||
)
|
||||
|
||||
# Convert Wh to the native measurement unit
|
||||
energy_per_day = wh_per_day
|
||||
if self._attr_native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR:
|
||||
energy_per_day = wh_per_day / 1000
|
||||
elif self._attr_native_unit_of_measurement == UnitOfEnergy.MEGA_WATT_HOUR:
|
||||
energy_per_day = wh_per_day / 1000000
|
||||
# Convert Wh/day to the sensor's native energy unit
|
||||
energy_per_day = EnergyConverter.convert(
|
||||
wh_per_day,
|
||||
UnitOfEnergy.WATT_HOUR,
|
||||
self._attr_native_unit_of_measurement,
|
||||
)
|
||||
|
||||
return Decimal((energy_per_day / 86400) * elapsed_seconds)
|
||||
|
||||
|
||||
@@ -11,14 +11,11 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDevic
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONF_NAME,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -41,9 +38,9 @@ from custom_components.powercalc.const import (
|
||||
DEFAULT_ENERGY_INTEGRATION_METHOD,
|
||||
DEFAULT_ENERGY_SENSOR_PRECISION,
|
||||
DEFAULT_ENERGY_UPDATE_INTERVAL,
|
||||
UNAVAILABLE_STATES,
|
||||
UnitPrefix,
|
||||
)
|
||||
from custom_components.powercalc.device_binding import get_device_info
|
||||
from custom_components.powercalc.errors import SensorConfigurationError
|
||||
from custom_components.powercalc.filter.outlier import OutlierFilter
|
||||
|
||||
@@ -60,6 +57,16 @@ ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _numeric_state_value(state: State | None) -> float | None:
|
||||
"""Return the numeric value of a state, or None when it is not a usable number."""
|
||||
if state is None or state.state in UNAVAILABLE_STATES:
|
||||
return None
|
||||
try:
|
||||
return float(state.state)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def create_energy_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
@@ -82,6 +89,17 @@ def create_energy_sensor(
|
||||
return _create_virtual_energy_sensor(hass, sensor_config, power_sensor, source_entity)
|
||||
|
||||
|
||||
def resolve_existing_energy_sensor(hass: HomeAssistant, energy_sensor_id: str) -> RealEnergySensor:
|
||||
"""Look up an existing energy sensor in the entity registry, raising when not found."""
|
||||
entity_entry = er.async_get(hass).async_get(energy_sensor_id)
|
||||
if entity_entry is None:
|
||||
raise SensorConfigurationError(
|
||||
f"No energy sensor with id {energy_sensor_id} found in your HA instance. "
|
||||
"Double check the `energy_sensor_id` setting",
|
||||
)
|
||||
return RealEnergySensor.from_registry_entry(entity_entry)
|
||||
|
||||
|
||||
def _get_existing_energy_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
@@ -90,19 +108,7 @@ def _get_existing_energy_sensor(
|
||||
if CONF_ENERGY_SENSOR_ID not in sensor_config:
|
||||
return None
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
energy_sensor_id = sensor_config[CONF_ENERGY_SENSOR_ID]
|
||||
entity_entry = ent_reg.async_get(energy_sensor_id)
|
||||
if entity_entry is None:
|
||||
raise SensorConfigurationError(
|
||||
f"No energy sensor with id {energy_sensor_id} found in your HA instance. "
|
||||
"Double check `energy_sensor_id` setting",
|
||||
)
|
||||
return RealEnergySensor(
|
||||
entity_entry.entity_id,
|
||||
entity_entry.name or entity_entry.original_name,
|
||||
entity_entry.unique_id,
|
||||
)
|
||||
return resolve_existing_energy_sensor(hass, sensor_config[CONF_ENERGY_SENSOR_ID])
|
||||
|
||||
|
||||
def _get_related_energy_sensor(
|
||||
@@ -178,7 +184,6 @@ def _create_virtual_energy_sensor(
|
||||
powercalc_source_entity=source_entity.entity_id if source_entity else None,
|
||||
powercalc_source_domain=source_entity.domain if source_entity else None,
|
||||
sensor_config=sensor_config,
|
||||
device_info=get_device_info(hass, sensor_config, source_entity),
|
||||
)
|
||||
|
||||
|
||||
@@ -230,12 +235,7 @@ def _find_related_real_energy_sensor(
|
||||
if not energy_sensors:
|
||||
return None
|
||||
|
||||
entity_entry = energy_sensors[0]
|
||||
return RealEnergySensor(
|
||||
entity_entry.entity_id,
|
||||
entity_entry.name or entity_entry.original_name,
|
||||
entity_entry.unique_id,
|
||||
)
|
||||
return RealEnergySensor.from_registry_entry(energy_sensors[0])
|
||||
|
||||
|
||||
class EnergySensor(BaseEntity):
|
||||
@@ -260,7 +260,6 @@ class VirtualEnergySensor(IntegrationSensor, EnergySensor):
|
||||
entity_category: EntityCategory | None = None,
|
||||
name: str | None = None,
|
||||
unit_prefix: str | None = None,
|
||||
device_info: DeviceInfo | None = None,
|
||||
) -> None:
|
||||
round_digits: int = int(sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION))
|
||||
integration_method: str = sensor_config.get(CONF_ENERGY_INTEGRATION_METHOD, DEFAULT_ENERGY_INTEGRATION_METHOD)
|
||||
@@ -274,7 +273,6 @@ class VirtualEnergySensor(IntegrationSensor, EnergySensor):
|
||||
"unit_time": UnitOfTime.HOURS,
|
||||
"integration_method": integration_method,
|
||||
"unique_id": unique_id,
|
||||
"device_info": device_info,
|
||||
"max_sub_interval": timedelta(
|
||||
seconds=sensor_config.get(CONF_ENERGY_UPDATE_INTERVAL, DEFAULT_ENERGY_UPDATE_INTERVAL),
|
||||
),
|
||||
@@ -301,27 +299,86 @@ class VirtualEnergySensor(IntegrationSensor, EnergySensor):
|
||||
max_z_score=3.5,
|
||||
max_expected_step=sensor_config.get(CONF_ENERGY_FILTER_OUTLIER_MAX, 1000),
|
||||
)
|
||||
self._last_accepted_value: float | None = None
|
||||
self._last_rejected_value: float | None = None
|
||||
|
||||
def _integrate_on_state_change(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
|
||||
"""Override to add outlier filtering."""
|
||||
"""Override to add outlier filtering.
|
||||
|
||||
new_state: State | None = kwargs.get("new_state")
|
||||
if new_state is None and args:
|
||||
last_arg = args[-1]
|
||||
if isinstance(last_arg, State):
|
||||
new_state = last_arg
|
||||
Simply skipping integration when an outlier arrives as the ``new_state`` is not
|
||||
enough: the energy sensor integrates over consecutive states, and depending on the
|
||||
integration method the outlier also contributes when it is the *old* state of the
|
||||
following event. With the default ``left`` Riemann method the contribution of a
|
||||
state change is ``old_state * elapsed_time``, so a rejected spike still leaks into
|
||||
the total on the next update. Instead of skipping, we substitute any outlier reading
|
||||
with the last accepted value wherever it appears (as old and as new state), so it can
|
||||
never affect the energy total regardless of the integration method.
|
||||
"""
|
||||
if not self._filter_outliers:
|
||||
super()._integrate_on_state_change(*args, **kwargs)
|
||||
return
|
||||
|
||||
if self._filter_outliers and new_state is not None:
|
||||
valid_state = new_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
|
||||
if valid_state and not self._outlier_filter.accept(float(new_state.state)):
|
||||
_LOGGER.debug(
|
||||
"%s: Rejecting power value %s as outlier for energy integration",
|
||||
self.entity_id,
|
||||
new_state.state,
|
||||
)
|
||||
return
|
||||
arg_list = list(args)
|
||||
state_positions = [index for index, value in enumerate(arg_list) if isinstance(value, State)]
|
||||
|
||||
super()._integrate_on_state_change(*args, **kwargs)
|
||||
# The integration sensor passes the states positionally (old_state, new_state being
|
||||
# the last two). Sanitize old_state first, then new_state, since processing new_state
|
||||
# updates the tracking used to detect the outlier as a subsequent old_state.
|
||||
if len(state_positions) >= 2:
|
||||
old_index = state_positions[-2]
|
||||
arg_list[old_index] = self._replace_outlier_state(arg_list[old_index])
|
||||
if state_positions:
|
||||
new_index = state_positions[-1]
|
||||
arg_list[new_index] = self._sanitize_new_state(arg_list[new_index])
|
||||
|
||||
super()._integrate_on_state_change(*arg_list, **kwargs)
|
||||
|
||||
def _schedule_max_sub_interval_exceeded_if_state_is_numeric(self, source_state: State | None) -> None:
|
||||
"""Prevent a rejected outlier from being integrated as the assumed constant value.
|
||||
|
||||
When ``max_sub_interval`` is configured (always the case for powercalc energy sensors)
|
||||
the integration sensor keeps integrating the last known source state until a new state
|
||||
change arrives. Substitute the outlier with the last accepted value so this fallback
|
||||
does not leak the spike either.
|
||||
"""
|
||||
if self._filter_outliers:
|
||||
source_state = self._replace_outlier_state(source_state)
|
||||
super()._schedule_max_sub_interval_exceeded_if_state_is_numeric(source_state)
|
||||
|
||||
def _sanitize_new_state(self, state: State | None) -> State | None:
|
||||
"""Feed a new state through the outlier filter, substituting rejected outliers."""
|
||||
value = _numeric_state_value(state)
|
||||
if value is None:
|
||||
return state
|
||||
|
||||
if self._outlier_filter.accept(value):
|
||||
self._last_accepted_value = value
|
||||
self._last_rejected_value = None
|
||||
return state
|
||||
|
||||
self._last_rejected_value = value
|
||||
_LOGGER.debug(
|
||||
"%s: Rejecting power value %s as outlier for energy integration",
|
||||
self.entity_id,
|
||||
state.state if state else value,
|
||||
)
|
||||
return self._replace_outlier_state(state)
|
||||
|
||||
def _replace_outlier_state(self, state: State | None) -> State | None:
|
||||
"""Replace a state holding the last rejected outlier value with the last accepted value."""
|
||||
if state is None or self._last_rejected_value is None or self._last_accepted_value is None:
|
||||
return state
|
||||
if _numeric_state_value(state) != self._last_rejected_value:
|
||||
return state
|
||||
return State(
|
||||
state.entity_id,
|
||||
str(self._last_accepted_value),
|
||||
state.attributes,
|
||||
last_changed=state.last_changed,
|
||||
last_reported=state.last_reported,
|
||||
last_updated=state.last_updated,
|
||||
context=state.context,
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str] | None:
|
||||
@@ -370,6 +427,11 @@ class RealEnergySensor(EnergySensor):
|
||||
self._name = name
|
||||
self._unique_id = unique_id
|
||||
|
||||
@classmethod
|
||||
def from_registry_entry(cls, entry: er.RegistryEntry) -> RealEnergySensor:
|
||||
"""Create a reference to an existing energy sensor from its registry entry."""
|
||||
return cls(entry.entity_id, entry.name or entry.original_name, entry.unique_id)
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Return the name of the sensor."""
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from custom_components.powercalc.common import SourceEntity
|
||||
from custom_components.powercalc.const import CONF_CREATE_COST_SENSOR
|
||||
|
||||
from .cost import create_cost_sensor
|
||||
from .energy import EnergySensor
|
||||
from .utility_meter import create_utility_meters
|
||||
|
||||
|
||||
def create_energy_related_sensors(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
energy_sensor: EnergySensor,
|
||||
source_entity: SourceEntity | None = None,
|
||||
config_entry: ConfigEntry | None = None,
|
||||
utility_meter_config: ConfigType | None = None,
|
||||
cost_name: str | None = None,
|
||||
) -> list[Entity]:
|
||||
"""Create optional utility meters and cost sensor for an energy sensor.
|
||||
|
||||
When cost sensors are enabled, a cost sensor is created for the energy sensor and,
|
||||
when utility meters are enabled as well, one additional cost sensor per utility meter.
|
||||
"""
|
||||
entities: list[Entity] = []
|
||||
meter_config = sensor_config if utility_meter_config is None else utility_meter_config
|
||||
utility_meters = create_utility_meters(hass, energy_sensor, meter_config, config_entry)
|
||||
entities.extend(utility_meters)
|
||||
|
||||
cost_sensor = create_cost_sensor_if_needed(hass, sensor_config, energy_sensor, source_entity, cost_name)
|
||||
if cost_sensor:
|
||||
entities.append(cost_sensor)
|
||||
# A cost sensor per utility meter, so each meter cycle (daily, monthly, ...) is priced individually.
|
||||
for utility_meter in utility_meters:
|
||||
meter_name = utility_meter.name if isinstance(utility_meter.name, str) else None
|
||||
# The utility meter resets each cycle, so the cost sensor must reset along with it.
|
||||
meter_cost_sensor = create_cost_sensor(
|
||||
hass,
|
||||
sensor_config,
|
||||
utility_meter,
|
||||
source_entity,
|
||||
meter_name,
|
||||
reset_on_source_reset=True,
|
||||
)
|
||||
if meter_cost_sensor:
|
||||
entities.append(meter_cost_sensor)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
def create_cost_sensor_if_needed(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
energy_sensor: EnergySensor,
|
||||
source_entity: SourceEntity | None = None,
|
||||
name: str | None = None,
|
||||
) -> Entity | None:
|
||||
"""Create a cost sensor when enabled and configured."""
|
||||
if not sensor_config.get(CONF_CREATE_COST_SENSOR):
|
||||
return None
|
||||
return create_cost_sensor(hass, sensor_config, energy_sensor, source_entity, name)
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user