updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
+20 -6
View File
@@ -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] = []
+52 -29
View File
@@ -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:
+45 -30
View File
@@ -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,
},
)
@@ -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),
)
+20 -1
View File
@@ -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"
+84 -85
View File
@@ -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)
+20 -17
View File
@@ -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):
@@ -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)
@@ -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
@@ -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)
+15 -36
View File
@@ -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,
),
),
)
+6
View File
@@ -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"
},
+1 -1
View File
@@ -22,5 +22,5 @@
"requirements": [
"numpy>=1.21.1"
],
"version": "v1.21.2"
"version": "v1.22.0"
}
+32 -8
View File
@@ -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)
@@ -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
+59 -304
View File
@@ -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
+84 -31
View File
@@ -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
+372
View File
@@ -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)
+104 -42
View File
@@ -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)

Some files were not shown because too many files have changed in this diff Show More