329 files
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
"""The PowerCalc integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
from collections import Counter
|
||||
from collections.abc import Hashable
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import NamedTuple
|
||||
@@ -40,6 +38,7 @@ class SourceEntity(NamedTuple):
|
||||
supported_color_modes: list[ColorMode] | None = None
|
||||
entity_entry: er.RegistryEntry | None = None
|
||||
device_entry: dr.DeviceEntry | None = None
|
||||
config_entry_id: str | None = None
|
||||
|
||||
|
||||
EXCLUDE_FROM_PARENT_CONFIG = (
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Config flow for Powercalc integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
from inspect import isawaitable
|
||||
@@ -33,6 +31,7 @@ from .common import SourceEntity, create_source_entity
|
||||
from .const import (
|
||||
CONF_CREATE_COST_SENSOR,
|
||||
CONF_CREATE_UTILITY_METERS,
|
||||
CONF_ENERGY_PRICE_SENSOR,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MODE,
|
||||
CONF_MODEL,
|
||||
@@ -47,6 +46,7 @@ from .const import (
|
||||
CalculationStrategy,
|
||||
SensorType,
|
||||
)
|
||||
from .device_binding import attach_configured_device_entry
|
||||
from .errors import ModelNotSupportedError, StrategyConfigurationError
|
||||
from .flow_helper.common import FlowType, PowercalcFormStep, Step, fill_schema_defaults, flatten_sections
|
||||
from .flow_helper.flows.cost import CostConfigFlow, CostOptionsFlow
|
||||
@@ -76,12 +76,13 @@ 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 (
|
||||
COST_DOCS_URI,
|
||||
SCHEMA_COST_PRICING,
|
||||
SCHEMA_COST_SENSOR_TOGGLE,
|
||||
SCHEMA_ENERGY_SENSOR_TOGGLE,
|
||||
SCHEMA_SENSOR_ENERGY_OPTIONS,
|
||||
SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE,
|
||||
SCHEMA_UTILITY_METER_OPTIONS,
|
||||
SCHEMA_UTILITY_METER_TOGGLE,
|
||||
build_cost_pricing_schema,
|
||||
)
|
||||
from .power_profile.factory import get_power_profile
|
||||
from .power_profile.power_profile import SUPPORTED_DOMAINS, DeviceType, DiscoveryBy, PowerProfile
|
||||
@@ -455,21 +456,21 @@ class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN):
|
||||
@callback
|
||||
def persist_config_entry(self) -> ConfigFlowResult:
|
||||
"""Create the config entry."""
|
||||
self.sensor_config.update({CONF_SENSOR_TYPE: self.selected_sensor_type})
|
||||
self.sensor_config.update({CONF_NAME: self.name})
|
||||
entry_data: ConfigType = {
|
||||
**self.sensor_config,
|
||||
CONF_SENSOR_TYPE: self.selected_sensor_type,
|
||||
CONF_NAME: self.name,
|
||||
}
|
||||
|
||||
if self.source_entity_id:
|
||||
self.sensor_config.update({CONF_ENTITY_ID: self.source_entity_id})
|
||||
entry_data[CONF_ENTITY_ID] = self.source_entity_id
|
||||
|
||||
if (
|
||||
self.selected_profile
|
||||
and self.source_entity
|
||||
and self.source_entity.device_entry
|
||||
and self.selected_profile.discovery_by == DiscoveryBy.DEVICE
|
||||
):
|
||||
self.sensor_config.update({CONF_DEVICE: self.source_entity.device_entry.id})
|
||||
profile = self.selected_profile
|
||||
source_entity = self.source_entity
|
||||
if profile and source_entity and profile.discovery_by == DiscoveryBy.DEVICE and source_entity.device_entry:
|
||||
entry_data[CONF_DEVICE] = source_entity.device_entry.id
|
||||
|
||||
return self.async_create_entry(title=str(self.name), data=self.sensor_config)
|
||||
return self.async_create_entry(title=str(self.name), data=entry_data)
|
||||
|
||||
|
||||
class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
|
||||
@@ -507,9 +508,13 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
|
||||
|
||||
self.sensor_config = dict(self.config_entry.data)
|
||||
if self.source_entity_id:
|
||||
self.source_entity = create_source_entity(
|
||||
self.source_entity_id,
|
||||
self.source_entity = attach_configured_device_entry(
|
||||
self.hass,
|
||||
self.sensor_config,
|
||||
create_source_entity(
|
||||
self.source_entity_id,
|
||||
self.hass,
|
||||
),
|
||||
)
|
||||
result = await self.initialize_library_profile()
|
||||
if result:
|
||||
@@ -599,7 +604,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
|
||||
"""Handle the per sensor energy price override."""
|
||||
return await self.async_handle_options_step(
|
||||
user_input,
|
||||
SCHEMA_COST_PRICING,
|
||||
build_cost_pricing_schema(self.hass, self.sensor_config.get(CONF_ENERGY_PRICE_SENSOR)),
|
||||
Step.COST_OPTIONS,
|
||||
form_kwarg={"description_placeholders": {"docs_uri": COST_DOCS_URI}},
|
||||
)
|
||||
@@ -731,6 +736,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
|
||||
return schema.extend( # type: ignore[no-any-return]
|
||||
{
|
||||
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_COST_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_UTILITY_METER_TOGGLE.schema,
|
||||
},
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,5 @@
|
||||
"""Convert config entry data to runtime sensor configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Normalize discovery info into sensor configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import CONF_ENTITY_ID
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Normalize persisted configuration shapes to runtime mappings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import CONF_ID, CONF_PATH
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Sensor configuration schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA
|
||||
@@ -21,6 +19,7 @@ from custom_components.powercalc.const import (
|
||||
CONF_CREATE_COST_SENSOR,
|
||||
CONF_CREATE_ENERGY_SENSOR,
|
||||
CONF_CREATE_GROUP,
|
||||
CONF_CREATE_STANDBY_ENERGY_SENSOR,
|
||||
CONF_CREATE_UTILITY_METERS,
|
||||
CONF_CUSTOM_MODEL_DIRECTORY,
|
||||
CONF_DAILY_FIXED_ENERGY,
|
||||
@@ -62,6 +61,7 @@ from custom_components.powercalc.const import (
|
||||
CONF_POWER_SENSOR_ID,
|
||||
CONF_POWER_SENSOR_NAMING,
|
||||
CONF_SLEEP_POWER,
|
||||
CONF_STANDBY_ENERGY_SENSOR_NAMING,
|
||||
CONF_STANDBY_POWER,
|
||||
CONF_SUBTRACT_ENTITIES,
|
||||
CONF_UNAVAILABLE_POWER,
|
||||
@@ -110,6 +110,7 @@ SENSOR_CONFIG = {
|
||||
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_STANDBY_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,
|
||||
@@ -126,6 +127,7 @@ SENSOR_CONFIG = {
|
||||
vol.Optional(CONF_ENERGY_PRICE_MULTIPLIER): vol.Coerce(float),
|
||||
vol.Optional(CONF_ENERGY_SENSOR_ID): cv.entity_id,
|
||||
vol.Optional(CONF_ENERGY_SENSOR_NAMING): validate_name_pattern,
|
||||
vol.Optional(CONF_STANDBY_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,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""The Powercalc constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
@@ -18,7 +16,7 @@ from homeassistant.const import (
|
||||
EntityCategory,
|
||||
)
|
||||
|
||||
MIN_HA_VERSION = "2026.1.0"
|
||||
MIN_HA_VERSION = "2026.4.0"
|
||||
|
||||
BUILT_IN_LIBRARY_DIR = "powercalc_profiles"
|
||||
|
||||
@@ -78,6 +76,7 @@ CONF_CREATE_ENERGY_SENSOR = "create_energy_sensor"
|
||||
CONF_CREATE_ENERGY_SENSORS = "create_energy_sensors"
|
||||
CONF_CREATE_GROUP = "create_group"
|
||||
CONF_CREATE_STANDBY_GROUP = "create_standby_group"
|
||||
CONF_CREATE_STANDBY_ENERGY_SENSOR = "create_standby_energy_sensor"
|
||||
CONF_CREATE_UTILITY_METERS = "create_utility_meters"
|
||||
CONF_CUSTOM_MODEL_DIRECTORY = "custom_model_directory"
|
||||
CONF_DAILY_FIXED_ENERGY = "daily_fixed_energy"
|
||||
@@ -165,6 +164,7 @@ CONF_SELF_USAGE_INCLUDED = "self_usage_included"
|
||||
CONF_SENSOR_TYPE = "sensor_type"
|
||||
CONF_SENSORS = "sensors"
|
||||
CONF_SLEEP_POWER = "sleep_power"
|
||||
CONF_STANDBY_ENERGY_SENSOR_NAMING = "standby_energy_sensor_naming"
|
||||
CONF_STANDBY_POWER = "standby_power"
|
||||
CONF_START_TIME = "start_time"
|
||||
CONF_STATE = "state"
|
||||
@@ -229,6 +229,7 @@ DEFAULT_POWER_SENSOR_PRECISION = 2
|
||||
DEFAULT_ENERGY_UPDATE_INTERVAL = 600
|
||||
DEFAULT_ENERGY_INTEGRATION_METHOD = ENERGY_INTEGRATION_METHOD_LEFT
|
||||
DEFAULT_ENERGY_NAME_PATTERN = "{} energy"
|
||||
DEFAULT_STANDBY_ENERGY_NAME_PATTERN = "{} standby energy"
|
||||
DEFAULT_SELF_USAGE_ENERGY_NAME_PATTERN = "{} Device Energy"
|
||||
DEFAULT_ENERGY_SENSOR_PRECISION = 4
|
||||
DEFAULT_ENERGY_UNIT_PREFIX = UnitPrefix.KILO
|
||||
|
||||
@@ -16,6 +16,8 @@ from custom_components.powercalc.const import CONF_AREA, DUMMY_ENTITY_ID
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_HAS_SINGLE_CONFIG_ENTRY = hasattr(DeviceEntry, "config_entry_id")
|
||||
|
||||
|
||||
def is_composite_device_id(hass: HomeAssistant, device_id: str) -> bool:
|
||||
"""
|
||||
@@ -29,6 +31,31 @@ def is_composite_device_id(hass: HomeAssistant, device_id: str) -> bool:
|
||||
return bool(is_composite(device_id))
|
||||
|
||||
|
||||
def get_config_entry_ids(device: DeviceEntry) -> set[str]:
|
||||
"""
|
||||
Return the config entry IDs a device belongs to.
|
||||
HA >=2026.8 splits composite devices, so a device belongs to exactly one config entry and
|
||||
carries a single config_entry_id. Older versions track the set of entries on the device itself.
|
||||
"""
|
||||
if _HAS_SINGLE_CONFIG_ENTRY:
|
||||
return {device.config_entry_id}
|
||||
return set(getattr(device, "config_entries", set()))
|
||||
|
||||
|
||||
def get_first_device_for_config_entry(hass: HomeAssistant, config_entry_id: str) -> DeviceEntry | None:
|
||||
"""Return the first non-composite device belonging to a config entry."""
|
||||
return next(iter(get_devices_for_config_entry(hass, config_entry_id)), None)
|
||||
|
||||
|
||||
def get_devices_for_config_entry(hass: HomeAssistant, config_entry_id: str) -> list[DeviceEntry]:
|
||||
"""Return all non-composite devices belonging to a config entry."""
|
||||
return [
|
||||
device
|
||||
for device in device_registry.async_get(hass).devices.values()
|
||||
if config_entry_id in get_config_entry_ids(device) and not is_composite_device_id(hass, device.id)
|
||||
]
|
||||
|
||||
|
||||
def attach_configured_device_entry(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
@@ -60,6 +87,7 @@ def attach_entities_to_resolved_device(
|
||||
for entity in entities_to_add:
|
||||
try:
|
||||
entity.device_entry = device_entry
|
||||
setattr(entity, "_powercalc_device_entry", device_entry) # noqa: B010
|
||||
except AttributeError: # pragma: no cover
|
||||
_LOGGER.error("%s: Cannot set device id on entity", entity.entity_id)
|
||||
|
||||
@@ -84,7 +112,7 @@ def get_device_entry(
|
||||
return None
|
||||
return device_registry.async_get(hass).async_get(device_id)
|
||||
|
||||
if source_entity:
|
||||
if source_entity and not source_entity.config_entry_id:
|
||||
return source_entity.device_entry or async_entity_id_to_device(hass, source_entity.entity_id)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timedelta
|
||||
from enum import StrEnum
|
||||
@@ -33,7 +31,7 @@ from .const import (
|
||||
MANUFACTURER_WLED,
|
||||
CalculationStrategy,
|
||||
)
|
||||
from .device_binding import is_composite_device_id
|
||||
from .device_binding import get_config_entry_ids, get_first_device_for_config_entry, is_composite_device_id
|
||||
from .group_include.filter import (
|
||||
CategoryFilter,
|
||||
CompositeFilter,
|
||||
@@ -49,7 +47,7 @@ from .power_profile.library import ModelInfo, ProfileLibrary
|
||||
from .power_profile.power_profile import SUPPORTED_DOMAINS, DeviceType, DiscoveryBy, PowerProfile
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_DiscoverySourceT = TypeVar("_DiscoverySourceT", er.RegistryEntry, dr.DeviceEntry)
|
||||
_DiscoverySourceT = TypeVar("_DiscoverySourceT", er.RegistryEntry, dr.DeviceEntry, ConfigEntry)
|
||||
|
||||
|
||||
def get_discovery_manager(hass: HomeAssistant) -> DiscoveryManager:
|
||||
@@ -165,6 +163,13 @@ class DiscoveryManager:
|
||||
_LOGGER.debug("Start device discovery")
|
||||
await self.perform_discovery(self.get_devices, self.create_device_source, DiscoveryBy.DEVICE)
|
||||
|
||||
_LOGGER.debug("Start config entry discovery")
|
||||
await self.perform_discovery(
|
||||
self.get_config_entries,
|
||||
self.create_config_entry_source,
|
||||
DiscoveryBy.CONFIG_ENTRY,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Done auto discovery")
|
||||
self._status = DiscoveryStatus.FINISHED
|
||||
|
||||
@@ -200,14 +205,17 @@ class DiscoveryManager:
|
||||
) -> None:
|
||||
"""Generalized discovery procedure for entities and devices."""
|
||||
for source in await source_provider():
|
||||
log_identifier = str(getattr(source, "entity_id", getattr(source, "id", "unknown")))
|
||||
log_identifier = str(
|
||||
getattr(source, "entity_id", getattr(source, "id", getattr(source, "entry_id", "unknown"))),
|
||||
)
|
||||
try:
|
||||
model_info = await self.extract_model_info_from_device_info(source)
|
||||
source_entity = await source_creator(source)
|
||||
model_info = await self.extract_model_info_from_device_info(
|
||||
source_entity.entity_entry or source_entity.device_entry,
|
||||
)
|
||||
if not model_info:
|
||||
continue
|
||||
|
||||
source_entity = await source_creator(source)
|
||||
|
||||
power_profiles = await self.discover_entity(source_entity, model_info, discovery_type)
|
||||
if not power_profiles:
|
||||
_LOGGER.debug("%s: Model not found in library, skipping discovery", log_identifier)
|
||||
@@ -263,9 +271,25 @@ class DiscoveryManager:
|
||||
device_entry=device_entry,
|
||||
)
|
||||
|
||||
async def create_config_entry_source(self, config_entry: ConfigEntry) -> SourceEntity:
|
||||
"""Create a source representing all devices belonging to a config entry."""
|
||||
device_entry = get_first_device_for_config_entry(self.hass, config_entry.entry_id)
|
||||
return SourceEntity(
|
||||
object_id=config_entry.entry_id,
|
||||
name=config_entry.title,
|
||||
entity_id=DUMMY_ENTITY_ID,
|
||||
domain="sensor",
|
||||
device_entry=device_entry,
|
||||
config_entry_id=config_entry.entry_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_unique_id(source: SourceEntity, discovery_type: DiscoveryBy, power_profile: PowerProfile | None) -> str:
|
||||
"""Generate a unique ID based on source and type."""
|
||||
if discovery_type == DiscoveryBy.CONFIG_ENTRY:
|
||||
config_entry_id = source.config_entry_id or source.object_id
|
||||
return f"pc_config_entry_{config_entry_id}"
|
||||
|
||||
if discovery_type == DiscoveryBy.DEVICE:
|
||||
device_id = source.object_id
|
||||
if source.device_entry:
|
||||
@@ -393,6 +417,20 @@ class DiscoveryManager:
|
||||
if not is_composite_device_id(self.hass, device.id)
|
||||
]
|
||||
|
||||
async def get_config_entries(self) -> list[ConfigEntry]:
|
||||
"""Fetch config entries which have at least one non-composite device."""
|
||||
config_entry_ids = {
|
||||
config_entry_id
|
||||
for device in dr.async_get(self.hass).devices.values()
|
||||
if not is_composite_device_id(self.hass, device.id)
|
||||
for config_entry_id in get_config_entry_ids(device)
|
||||
}
|
||||
return [
|
||||
entry
|
||||
for entry in self.hass.config_entries.async_entries()
|
||||
if entry.domain != DOMAIN and entry.entry_id in config_entry_ids
|
||||
]
|
||||
|
||||
def enable(self) -> None:
|
||||
"""Enable the discovery."""
|
||||
self._status = DiscoveryStatus.NOT_STARTED
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Errors for the power component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import statistics
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -39,6 +39,7 @@ class Step(StrEnum):
|
||||
MENU_LIBRARY = "menu_library"
|
||||
MENU_GROUP = "menu_group"
|
||||
MODEL = "model"
|
||||
SELECT_DEVICE = "select_device"
|
||||
SUB_PROFILE = "sub_profile"
|
||||
SUB_PROFILE_PER_DEVICE = "sub_profile_per_device"
|
||||
USER = "user"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -1,12 +1,11 @@
|
||||
"""Config/options flow for a standalone cost sensor based on an existing energy sensor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
|
||||
@@ -21,28 +20,35 @@ from custom_components.powercalc.const import (
|
||||
SensorType,
|
||||
)
|
||||
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, flatten_sections
|
||||
from custom_components.powercalc.flow_helper.schema import SCHEMA_COST_PRICING, SECTION_COST_PRICING
|
||||
from custom_components.powercalc.flow_helper.schema import SECTION_COST_PRICING, build_cost_pricing_schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from custom_components.powercalc.config_flow import PowercalcConfigFlow, PowercalcOptionsFlow
|
||||
|
||||
SCHEMA_COST_OPTIONS = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ENERGY_SENSOR_ID): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="sensor", device_class=SensorDeviceClass.ENERGY),
|
||||
),
|
||||
vol.Optional(SECTION_COST_PRICING): section(SCHEMA_COST_PRICING, {"collapsed": True}),
|
||||
},
|
||||
)
|
||||
|
||||
SCHEMA_COST = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): selector.TextSelector(),
|
||||
**SCHEMA_COST_OPTIONS.schema,
|
||||
},
|
||||
)
|
||||
def build_cost_options_schema(hass: HomeAssistant, current_price_entity_id: str | None = None) -> vol.Schema:
|
||||
"""Return the schema for the options of a standalone cost sensor."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ENERGY_SENSOR_ID): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="sensor", device_class=SensorDeviceClass.ENERGY),
|
||||
),
|
||||
vol.Optional(SECTION_COST_PRICING): section(
|
||||
build_cost_pricing_schema(hass, current_price_entity_id),
|
||||
{"collapsed": True},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_cost_schema(hass: HomeAssistant) -> vol.Schema:
|
||||
"""Return the schema for creating a standalone cost sensor."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): selector.TextSelector(),
|
||||
**build_cost_options_schema(hass).schema,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def is_global_price_configured(hass: HomeAssistant) -> bool:
|
||||
@@ -68,13 +74,17 @@ class CostConfigFlow:
|
||||
"""Handle the flow for a standalone cost sensor."""
|
||||
self.flow.selected_sensor_type = SensorType.COST
|
||||
return await self.flow.handle_form_step(
|
||||
PowercalcFormStep(step=Step.COST, schema=SCHEMA_COST, validate_user_input=self.validate),
|
||||
PowercalcFormStep(
|
||||
step=Step.COST,
|
||||
schema=build_cost_schema(self.flow.hass),
|
||||
validate_user_input=self.validate,
|
||||
),
|
||||
user_input,
|
||||
)
|
||||
|
||||
def validate(self, user_input: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Flatten the pricing section and reject the input when no price is available."""
|
||||
user_input = flatten_sections(user_input, SCHEMA_COST)
|
||||
user_input = flatten_sections(user_input, build_cost_schema(self.flow.hass))
|
||||
if validate_price_configured(self.flow.hass, user_input):
|
||||
raise SchemaFlowError("cost_price_mandatory")
|
||||
return user_input
|
||||
@@ -88,7 +98,7 @@ class CostOptionsFlow:
|
||||
"""Handle the cost sensor options flow."""
|
||||
return await self.flow.async_handle_options_step(
|
||||
user_input,
|
||||
SCHEMA_COST_OPTIONS,
|
||||
build_cost_options_schema(self.flow.hass, self.flow.sensor_config.get(CONF_ENERGY_PRICE_SENSOR)),
|
||||
Step.COST,
|
||||
validate=lambda flat_input: validate_price_configured(self.flow.hass, flat_input),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -55,12 +53,12 @@ from custom_components.powercalc.flow_helper.schema import (
|
||||
COST_DOCS_URI,
|
||||
SCHEMA_COST_APPLY,
|
||||
SCHEMA_ENERGY_OPTIONS,
|
||||
SCHEMA_GLOBAL_COST,
|
||||
SCHEMA_GLOBAL_COST_FLAT,
|
||||
SCHEMA_UTILITY_METER_OPTIONS,
|
||||
SCHEMA_UTILITY_METER_TOGGLE,
|
||||
SECTION_COST_NAMING,
|
||||
SECTION_COST_PRICING,
|
||||
build_global_cost_schema,
|
||||
)
|
||||
from custom_components.powercalc.power_profile.power_profile import DeviceType
|
||||
from custom_components.powercalc.service.gui_configuration import apply_field_to_config_entries
|
||||
@@ -325,7 +323,7 @@ class GlobalConfigurationFlow:
|
||||
|
||||
form_step = PowercalcFormStep(
|
||||
step=Step.GLOBAL_CONFIGURATION_COST,
|
||||
schema=SCHEMA_GLOBAL_COST,
|
||||
schema=build_global_cost_schema(self.flow.hass, self.flow.global_config.get(CONF_ENERGY_PRICE_SENSOR)),
|
||||
form_kwarg={
|
||||
"description_placeholders": {
|
||||
"docs_uri": COST_DOCS_URI,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Group-related logic for the config flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE
|
||||
from homeassistant.helpers import selector, translation
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -21,6 +20,7 @@ from custom_components.powercalc.const import (
|
||||
LIBRARY_URL,
|
||||
CalculationStrategy,
|
||||
)
|
||||
from custom_components.powercalc.device_binding import get_devices_for_config_entry
|
||||
from custom_components.powercalc.discovery import (
|
||||
get_power_profile_by_source_device,
|
||||
get_power_profile_by_source_entity,
|
||||
@@ -29,6 +29,7 @@ from custom_components.powercalc.flow_helper.common import FlowType, PowercalcFo
|
||||
from custom_components.powercalc.flow_helper.dynamic_field_builder import build_dynamic_field_schema
|
||||
from custom_components.powercalc.flow_helper.schema import (
|
||||
SCHEMA_ENERGY_SENSOR_TOGGLE,
|
||||
SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE,
|
||||
SCHEMA_UTILITY_METER_TOGGLE,
|
||||
build_sub_profile_schema,
|
||||
)
|
||||
@@ -58,6 +59,7 @@ SCHEMA_POWER_AUTODISCOVERED = vol.Schema(
|
||||
SCHEMA_POWER_OPTIONS_LIBRARY = vol.Schema(
|
||||
{
|
||||
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_UTILITY_METER_TOGGLE.schema,
|
||||
},
|
||||
)
|
||||
@@ -208,6 +210,9 @@ class LibraryFlow:
|
||||
if Step.LIBRARY_CUSTOM_FIELDS not in handled_steps and profile.has_custom_fields:
|
||||
return await self.async_step_library_custom_fields()
|
||||
|
||||
if Step.SELECT_DEVICE not in handled_steps and profile.discovery_by == DiscoveryBy.CONFIG_ENTRY:
|
||||
return await self.async_step_select_device()
|
||||
|
||||
if Step.AVAILABILITY_ENTITY not in handled_steps and profile.discovery_by == DiscoveryBy.DEVICE:
|
||||
result = await self.async_step_availability_entity()
|
||||
if result:
|
||||
@@ -218,6 +223,35 @@ class LibraryFlow:
|
||||
|
||||
return None
|
||||
|
||||
async def async_step_select_device(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
"""Ask which device should receive the entities created for a config-entry profile."""
|
||||
assert self.flow.source_entity is not None
|
||||
assert self.flow.source_entity.config_entry_id is not None
|
||||
devices = get_devices_for_config_entry(self.flow.hass, self.flow.source_entity.config_entry_id)
|
||||
return await self.flow.handle_form_step(
|
||||
PowercalcFormStep(
|
||||
step=Step.SELECT_DEVICE,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
selector.SelectOptionDict(
|
||||
value=device.id,
|
||||
label=device.name_by_user or device.name or device.id,
|
||||
)
|
||||
for device in devices
|
||||
],
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
next_step=Step.POST_LIBRARY,
|
||||
),
|
||||
user_input,
|
||||
)
|
||||
|
||||
async def _async_next_strategy_step(self, profile: PowerProfile) -> ConfigFlowResult | None:
|
||||
"""Return the next step needed to configure the calculation strategy, or None when nothing is left to ask."""
|
||||
handled_steps = self.flow.handled_steps
|
||||
@@ -397,7 +431,7 @@ class LibraryFlow:
|
||||
|
||||
def _get_library_device_types(self) -> set[DeviceType] | None:
|
||||
"""Determine which device types should be shown in the library selectors."""
|
||||
if self._get_library_discovery_by() == DiscoveryBy.DEVICE:
|
||||
if self._get_library_discovery_by() in (DiscoveryBy.CONFIG_ENTRY, DiscoveryBy.DEVICE):
|
||||
return None
|
||||
|
||||
if self.flow.source_entity:
|
||||
@@ -407,9 +441,19 @@ class LibraryFlow:
|
||||
|
||||
def _get_library_discovery_by(self) -> DiscoveryBy | None:
|
||||
"""Determine whether listing should be filtered by discovery mode."""
|
||||
if self.flow.source_entity and self.flow.source_entity.entity_id == DUMMY_ENTITY_ID:
|
||||
return DiscoveryBy.DEVICE
|
||||
return None
|
||||
source_entity = self.flow.source_entity
|
||||
if not source_entity:
|
||||
return None
|
||||
|
||||
if source_entity.config_entry_id:
|
||||
return DiscoveryBy.CONFIG_ENTRY
|
||||
if source_entity.entity_id != DUMMY_ENTITY_ID:
|
||||
return None
|
||||
|
||||
profile = self.flow.selected_profile
|
||||
if profile and profile.discovery_by == DiscoveryBy.CONFIG_ENTRY:
|
||||
return DiscoveryBy.CONFIG_ENTRY
|
||||
return DiscoveryBy.DEVICE
|
||||
|
||||
|
||||
class LibraryConfigFlow(LibraryFlow):
|
||||
@@ -546,19 +590,22 @@ class LibraryConfigFlow(LibraryFlow):
|
||||
|
||||
def _get_profile_source(self, profile: PowerProfile) -> str:
|
||||
"""Build the autodiscovery source description."""
|
||||
source_entity = self.flow.source_entity
|
||||
if source_entity and profile.discovery_by == DiscoveryBy.CONFIG_ENTRY:
|
||||
config_entry_id = source_entity.config_entry_id
|
||||
if config_entry_id:
|
||||
return source_entity.name or config_entry_id
|
||||
|
||||
translations = translation.async_get_cached_translations(
|
||||
self.flow.hass,
|
||||
self.flow.hass.config.language,
|
||||
"common",
|
||||
DOMAIN,
|
||||
)
|
||||
if (
|
||||
profile.discovery_by == DiscoveryBy.DEVICE
|
||||
and self.flow.source_entity
|
||||
and self.flow.source_entity.device_entry
|
||||
):
|
||||
device_entry = source_entity.device_entry if source_entity else None
|
||||
if profile.discovery_by == DiscoveryBy.DEVICE and device_entry:
|
||||
label = translations.get(f"component.{DOMAIN}.common.source_device")
|
||||
return f"{label}: {self.flow.source_entity.device_entry.name}"
|
||||
return f"{label}: {device_entry.name}"
|
||||
|
||||
return f"{translations.get(f'component.{DOMAIN}.common.source_entity')}: {self.flow.source_entity_id}"
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Real-power logic for the config flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -16,6 +14,7 @@ from custom_components.powercalc.const import (
|
||||
CONF_CALCULATION_ENABLED_CONDITION,
|
||||
CONF_CALIBRATE,
|
||||
CONF_CREATE_ENERGY_SENSOR,
|
||||
CONF_CREATE_STANDBY_ENERGY_SENSOR,
|
||||
CONF_CREATE_UTILITY_METERS,
|
||||
CONF_FIXED,
|
||||
CONF_FIXED_VALUE,
|
||||
@@ -57,6 +56,7 @@ from custom_components.powercalc.flow_helper.profile_preview import PREVIEW_NAME
|
||||
from custom_components.powercalc.flow_helper.schema import (
|
||||
SCHEMA_ENERGY_SENSOR_TOGGLE,
|
||||
SCHEMA_SENSOR_ENERGY_OPTIONS,
|
||||
SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE,
|
||||
SCHEMA_UTILITY_METER_TOGGLE,
|
||||
)
|
||||
from custom_components.powercalc.flow_helper.strategy_form import (
|
||||
@@ -92,6 +92,7 @@ SCHEMA_POWER_OPTIONS = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_STANDBY_POWER): vol.Coerce(float),
|
||||
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE.schema,
|
||||
**SCHEMA_UTILITY_METER_TOGGLE.schema,
|
||||
},
|
||||
)
|
||||
@@ -358,7 +359,9 @@ class VirtualPowerFlow:
|
||||
return self.flow.persist_config_entry()
|
||||
|
||||
schema = SCHEMA_POWER_ADVANCED
|
||||
if self.flow.sensor_config.get(CONF_CREATE_ENERGY_SENSOR):
|
||||
if self.flow.sensor_config.get(CONF_CREATE_ENERGY_SENSOR) or self.flow.sensor_config.get(
|
||||
CONF_CREATE_STANDBY_ENERGY_SENSOR,
|
||||
):
|
||||
schema = schema.extend(SCHEMA_SENSOR_ENERGY_OPTIONS.schema)
|
||||
|
||||
return await self.flow.handle_form_step(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.utility_meter.const import CONF_METER_TYPE, METER_TYPES
|
||||
from homeassistant.const import UnitOfPower
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.selector import NumberSelector, NumberSelectorMode
|
||||
from homeassistant.util.unit_conversion import EnergyConverter
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.powercalc.const import (
|
||||
@@ -12,6 +14,7 @@ from custom_components.powercalc.const import (
|
||||
CONF_COST_SENSOR_NAMING,
|
||||
CONF_CREATE_COST_SENSOR,
|
||||
CONF_CREATE_ENERGY_SENSOR,
|
||||
CONF_CREATE_STANDBY_ENERGY_SENSOR,
|
||||
CONF_CREATE_UTILITY_METERS,
|
||||
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
|
||||
CONF_ENERGY_FILTER_OUTLIER_MAX,
|
||||
@@ -44,6 +47,12 @@ SCHEMA_ENERGY_SENSOR_TOGGLE = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
SCHEMA_STANDBY_ENERGY_SENSOR_TOGGLE = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_CREATE_STANDBY_ENERGY_SENSOR, default=False): selector.BooleanSelector(),
|
||||
},
|
||||
)
|
||||
|
||||
SCHEMA_COST_SENSOR_TOGGLE = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_CREATE_COST_SENSOR, default=False): selector.BooleanSelector(),
|
||||
@@ -61,8 +70,10 @@ SCHEMA_COST_PRICING = vol.Schema(
|
||||
vol.Optional(CONF_ENERGY_PRICE): NumberSelector(
|
||||
selector.NumberSelectorConfig(mode=NumberSelectorMode.BOX, step="any"),
|
||||
),
|
||||
# Unfiltered fallback. Prefer `build_cost_pricing_schema()` when a `hass` instance is
|
||||
# available, it narrows this picker down to the price sensors actually present.
|
||||
vol.Optional(CONF_ENERGY_PRICE_SENSOR): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="sensor", device_class=SensorDeviceClass.MONETARY),
|
||||
selector.EntitySelectorConfig(domain=SENSOR_DOMAIN),
|
||||
),
|
||||
vol.Optional(CONF_ENERGY_PRICE_SURCHARGE): NumberSelector(
|
||||
selector.NumberSelectorConfig(mode=NumberSelectorMode.BOX, step="any"),
|
||||
@@ -80,13 +91,71 @@ SCHEMA_GLOBAL_COST_NAMING = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
# Presented in the GUI as two collapsible sections (pricing and naming).
|
||||
SCHEMA_GLOBAL_COST = vol.Schema(
|
||||
{
|
||||
vol.Required(SECTION_COST_PRICING): section(SCHEMA_COST_PRICING),
|
||||
vol.Required(SECTION_COST_NAMING): section(SCHEMA_GLOBAL_COST_NAMING, {"collapsed": True}),
|
||||
},
|
||||
)
|
||||
|
||||
def is_energy_price_unit(unit: str | None) -> bool:
|
||||
"""Check whether a unit of measurement expresses a price per unit of energy.
|
||||
|
||||
Price sensors use `<currency>/<energy unit>`, for example `€/kWh`, `EUR/kWh` or `ct/MWh`.
|
||||
The currency part is free form (currency codes, symbols and cents are all in the wild), so
|
||||
only the energy denominator is validated, mirroring what the cost sensor accepts at runtime.
|
||||
"""
|
||||
if not unit or "/" not in unit:
|
||||
return False
|
||||
currency, _, denominator = unit.rpartition("/")
|
||||
return bool(currency.strip()) and denominator.strip() in EnergyConverter.VALID_UNITS
|
||||
|
||||
|
||||
def _energy_price_units(hass: HomeAssistant, current_entity_id: str | None) -> list[str] | None:
|
||||
"""Collect the price units to filter the energy price sensor picker on.
|
||||
|
||||
Returns None when the picker must not be filtered at all. The Home Assistant frontend
|
||||
matches this filter on the exact unit string and hides entities without a unit, so filtering
|
||||
is only safe when we can be sure it does not hide a sensor the user needs: the units are
|
||||
taken from the sensors actually present rather than from a hardcoded list of currencies, and
|
||||
an already configured price sensor which would not survive the filter disables it entirely.
|
||||
"""
|
||||
if current_entity_id:
|
||||
current_state = hass.states.get(current_entity_id)
|
||||
if not current_state or not is_energy_price_unit(current_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
|
||||
return None
|
||||
|
||||
units = {
|
||||
unit
|
||||
for state in hass.states.async_all(SENSOR_DOMAIN)
|
||||
if is_energy_price_unit(unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
|
||||
}
|
||||
return sorted(units) or None
|
||||
|
||||
|
||||
def build_cost_pricing_schema(hass: HomeAssistant, current_entity_id: str | None = None) -> vol.Schema:
|
||||
"""Return the pricing schema with the energy price sensor picker limited to price sensors.
|
||||
|
||||
`current_entity_id` is the price sensor already configured, if any, so an options flow never
|
||||
hides the current selection.
|
||||
"""
|
||||
units = _energy_price_units(hass, current_entity_id)
|
||||
if units is None:
|
||||
return SCHEMA_COST_PRICING
|
||||
|
||||
schema: vol.Schema = SCHEMA_COST_PRICING.extend(
|
||||
{
|
||||
vol.Optional(CONF_ENERGY_PRICE_SENSOR): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(filter={"domain": SENSOR_DOMAIN, "unit_of_measurement": units}),
|
||||
),
|
||||
},
|
||||
)
|
||||
return schema
|
||||
|
||||
|
||||
def build_global_cost_schema(hass: HomeAssistant, current_entity_id: str | None = None) -> vol.Schema:
|
||||
"""Return the global cost schema, presented in the GUI as two sections (pricing and naming)."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(SECTION_COST_PRICING): section(build_cost_pricing_schema(hass, current_entity_id)),
|
||||
vol.Required(SECTION_COST_NAMING): section(SCHEMA_GLOBAL_COST_NAMING, {"collapsed": True}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Flat variant with all cost keys, used to merge/clear the (un)nested user input.
|
||||
SCHEMA_GLOBAL_COST_FLAT = SCHEMA_COST_PRICING.extend(SCHEMA_GLOBAL_COST_NAMING.schema)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from custom_components.powercalc.configuration.normalization import normalize_state_trigger
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from enum import StrEnum
|
||||
import re
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"requirements": [
|
||||
"numpy>=1.21.1"
|
||||
],
|
||||
"version": "v1.23.2"
|
||||
"version": "v1.24.0"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
@@ -68,6 +66,7 @@ class DeviceType(StrEnum):
|
||||
|
||||
|
||||
class DiscoveryBy(StrEnum):
|
||||
CONFIG_ENTRY = "config_entry"
|
||||
DEVICE = "device"
|
||||
ENTITY = "entity"
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
import re
|
||||
from typing import Any, NamedTuple, Protocol
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Platform for sensor integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Platform for sensor integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
@@ -45,6 +43,7 @@ from .const import (
|
||||
CONF_COST,
|
||||
CONF_CREATE_ENERGY_SENSOR,
|
||||
CONF_CREATE_GROUP,
|
||||
CONF_CREATE_STANDBY_ENERGY_SENSOR,
|
||||
CONF_DAILY_FIXED_ENERGY,
|
||||
CONF_ENERGY_SENSOR_ID,
|
||||
CONF_FORCE_ENERGY_SENSOR_CREATION,
|
||||
@@ -87,7 +86,10 @@ from .const import (
|
||||
PowercalcDiscoveryType,
|
||||
SensorType,
|
||||
)
|
||||
from .device_binding import attach_configured_device_entry, attach_entities_to_resolved_device
|
||||
from .device_binding import (
|
||||
attach_configured_device_entry,
|
||||
attach_entities_to_resolved_device,
|
||||
)
|
||||
from .errors import (
|
||||
PowercalcSetupError,
|
||||
SensorAlreadyConfiguredError,
|
||||
@@ -100,7 +102,7 @@ from .sensors.daily_energy import (
|
||||
create_daily_fixed_energy_power_sensor,
|
||||
create_daily_fixed_energy_sensor,
|
||||
)
|
||||
from .sensors.energy import EnergySensor, create_energy_sensor
|
||||
from .sensors.energy import EnergySensor, create_energy_sensor, create_standby_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
|
||||
@@ -692,6 +694,8 @@ async def create_individual_sensors(
|
||||
except PowercalcSetupError:
|
||||
return EntitiesBucket()
|
||||
energy_sensor = _add_power_and_energy_sensor(hass, sensor_config, source_entity, power_sensor, entities_to_add)
|
||||
if sensor_config.get(CONF_CREATE_STANDBY_ENERGY_SENSOR) and isinstance(power_sensor, VirtualPowerSensor):
|
||||
entities_to_add.append(create_standby_energy_sensor(hass, sensor_config, power_sensor, source_entity))
|
||||
|
||||
if energy_sensor:
|
||||
entities_to_add.extend(
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
@@ -19,9 +17,11 @@ from custom_components.powercalc.const import (
|
||||
CONF_ENERGY_SENSOR_NAMING,
|
||||
CONF_POWER_SENSOR_FRIENDLY_NAMING,
|
||||
CONF_POWER_SENSOR_NAMING,
|
||||
CONF_STANDBY_ENERGY_SENSOR_NAMING,
|
||||
DEFAULT_COST_NAME_PATTERN,
|
||||
DEFAULT_ENERGY_NAME_PATTERN,
|
||||
DEFAULT_POWER_NAME_PATTERN,
|
||||
DEFAULT_STANDBY_ENERGY_NAME_PATTERN,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.powercalc.device_binding import bind_entity_to_registry_metadata
|
||||
@@ -39,7 +39,7 @@ class BaseEntity(Entity):
|
||||
bind_entity_to_registry_metadata(
|
||||
self.hass,
|
||||
self.entity_id,
|
||||
cast(DeviceEntry | None, getattr(self, "device_entry", None)),
|
||||
cast(DeviceEntry | None, getattr(self, "_powercalc_device_entry", None)),
|
||||
cast(ConfigType | None, getattr(self, "_sensor_config", None)),
|
||||
)
|
||||
|
||||
@@ -76,6 +76,22 @@ def generate_energy_sensor_name(
|
||||
)
|
||||
|
||||
|
||||
def generate_standby_energy_sensor_name(
|
||||
sensor_config: ConfigType,
|
||||
name: str | None = None,
|
||||
source_entity: SourceEntity | None = None,
|
||||
) -> str:
|
||||
"""Generate the name to use for a standby energy sensor."""
|
||||
return _generate_sensor_name(
|
||||
sensor_config,
|
||||
CONF_STANDBY_ENERGY_SENSOR_NAMING,
|
||||
CONF_STANDBY_ENERGY_SENSOR_NAMING,
|
||||
DEFAULT_STANDBY_ENERGY_NAME_PATTERN,
|
||||
name,
|
||||
source_entity,
|
||||
)
|
||||
|
||||
|
||||
def generate_cost_sensor_name(
|
||||
sensor_config: ConfigType,
|
||||
name: str | None = None,
|
||||
@@ -152,6 +168,26 @@ def generate_energy_sensor_entity_id(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def generate_standby_energy_sensor_entity_id(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
source_entity: SourceEntity | None = None,
|
||||
name: str | None = None,
|
||||
unique_id: str | None = None,
|
||||
) -> str:
|
||||
"""Generate the entity ID to use for a standby energy sensor."""
|
||||
return _generate_sensor_entity_id(
|
||||
hass,
|
||||
sensor_config,
|
||||
CONF_STANDBY_ENERGY_SENSOR_NAMING,
|
||||
DEFAULT_STANDBY_ENERGY_NAME_PATTERN,
|
||||
source_entity,
|
||||
name,
|
||||
unique_id,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def generate_cost_sensor_entity_id(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
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 (
|
||||
@@ -40,11 +38,7 @@ from .abstract import (
|
||||
generate_cost_sensor_name,
|
||||
)
|
||||
from .energy import EnergySensor, resolve_existing_energy_sensor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from .utility_meter import VirtualUtilityMeter
|
||||
from .utility_meter import VirtualUtilityMeter
|
||||
|
||||
COST_ICON = "mdi:cash"
|
||||
ATTR_LAST_ENERGY = "last_energy"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, time, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import inspect
|
||||
import logging
|
||||
@@ -48,8 +46,10 @@ from .abstract import (
|
||||
BaseEntity,
|
||||
generate_energy_sensor_entity_id,
|
||||
generate_energy_sensor_name,
|
||||
generate_standby_energy_sensor_entity_id,
|
||||
generate_standby_energy_sensor_name,
|
||||
)
|
||||
from .power import PowerSensor, RealPowerSensor
|
||||
from .power import PowerSensor, RealPowerSensor, VirtualPowerSensor
|
||||
|
||||
ENERGY_ICON = "mdi:lightning-bolt"
|
||||
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
|
||||
@@ -89,6 +89,46 @@ def create_energy_sensor(
|
||||
return _create_virtual_energy_sensor(hass, sensor_config, power_sensor, source_entity)
|
||||
|
||||
|
||||
def create_standby_energy_sensor(
|
||||
hass: HomeAssistant,
|
||||
sensor_config: ConfigType,
|
||||
power_sensor: VirtualPowerSensor,
|
||||
source_entity: SourceEntity,
|
||||
) -> VirtualStandbyEnergySensor:
|
||||
"""Create an energy sensor which only integrates standby power."""
|
||||
name = generate_standby_energy_sensor_name(sensor_config, sensor_config.get(CONF_NAME), source_entity)
|
||||
unique_id = f"{power_sensor.unique_id}_standby_energy" if power_sensor.unique_id is not None else None
|
||||
entity_id = generate_standby_energy_sensor_entity_id(
|
||||
hass,
|
||||
sensor_config,
|
||||
source_entity,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
entity_category = sensor_config.get(CONF_ENERGY_SENSOR_CATEGORY)
|
||||
unit_prefix = get_unit_prefix(hass, sensor_config, power_sensor)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Creating standby energy sensor (entity_id=%s, source_entity=%s, unit_prefix=%s)",
|
||||
entity_id,
|
||||
power_sensor.entity_id,
|
||||
unit_prefix,
|
||||
)
|
||||
|
||||
return VirtualStandbyEnergySensor(
|
||||
hass=hass,
|
||||
source_entity=power_sensor.entity_id,
|
||||
unique_id=unique_id,
|
||||
entity_id=entity_id,
|
||||
entity_category=entity_category,
|
||||
name=name,
|
||||
unit_prefix=unit_prefix,
|
||||
powercalc_source_entity=source_entity.entity_id,
|
||||
powercalc_source_domain=source_entity.domain,
|
||||
sensor_config=sensor_config,
|
||||
power_sensor=power_sensor,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -414,6 +454,78 @@ class VirtualEnergySensor(IntegrationSensor, EnergySensor):
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class VirtualStandbyEnergySensor(VirtualEnergySensor):
|
||||
"""Energy sensor integrating only the standby portion of a virtual power sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
source_entity: str,
|
||||
entity_id: str,
|
||||
sensor_config: ConfigType,
|
||||
power_sensor: VirtualPowerSensor,
|
||||
powercalc_source_entity: str | None = None,
|
||||
powercalc_source_domain: str | None = None,
|
||||
unique_id: str | None = None,
|
||||
entity_category: EntityCategory | None = None,
|
||||
name: str | None = None,
|
||||
unit_prefix: str | None = None,
|
||||
) -> None:
|
||||
self._power_sensor = power_sensor
|
||||
self._last_standby_power = power_sensor.current_standby_power
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
source_entity=source_entity,
|
||||
entity_id=entity_id,
|
||||
sensor_config=sensor_config,
|
||||
powercalc_source_entity=powercalc_source_entity,
|
||||
powercalc_source_domain=powercalc_source_domain,
|
||||
unique_id=unique_id,
|
||||
entity_category=entity_category,
|
||||
name=name,
|
||||
unit_prefix=unit_prefix,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Initialize standby tracking before registering integration callbacks."""
|
||||
self._last_standby_power = self._power_sensor.current_standby_power
|
||||
await super().async_added_to_hass()
|
||||
|
||||
def _integrate_on_state_change(
|
||||
self,
|
||||
old_timestamp: datetime | None,
|
||||
new_timestamp: datetime | None,
|
||||
old_state: State | None,
|
||||
new_state: State | None,
|
||||
) -> None:
|
||||
"""Replace total power states with their standby component before integrating."""
|
||||
current_standby_power = self._power_sensor.current_standby_power
|
||||
old_state = self._state_with_power(old_state, self._last_standby_power)
|
||||
new_state = self._state_with_power(new_state, current_standby_power)
|
||||
self._last_standby_power = current_standby_power
|
||||
super()._integrate_on_state_change(old_timestamp, new_timestamp, old_state, new_state)
|
||||
|
||||
def _schedule_max_sub_interval_exceeded_if_state_is_numeric(self, source_state: State | None) -> None:
|
||||
"""Schedule periodic integration using standby power rather than total power."""
|
||||
super()._schedule_max_sub_interval_exceeded_if_state_is_numeric(
|
||||
self._state_with_power(source_state, self._power_sensor.current_standby_power),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _state_with_power(state: State | None, power: Decimal) -> State | None:
|
||||
if state is None or state.state in UNAVAILABLE_STATES:
|
||||
return state
|
||||
return State(
|
||||
state.entity_id,
|
||||
str(power),
|
||||
state.attributes,
|
||||
last_changed=state.last_changed,
|
||||
last_reported=state.last_reported,
|
||||
last_updated=state.last_updated,
|
||||
context=state.context,
|
||||
)
|
||||
|
||||
|
||||
class RealEnergySensor(EnergySensor):
|
||||
"""Contains a reference to an existing energy sensor entity."""
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user