Initial Commit

This commit is contained in:
2026-06-11 11:50:50 -04:00
commit d4a69c41be
2748 changed files with 80489 additions and 0 deletions
+571
View File
@@ -0,0 +1,571 @@
"""The PowerCalc integration."""
from __future__ import annotations
import asyncio
from functools import partial
import logging
import random
import time
from typing import Any
from awesomeversion.awesomeversion import AwesomeVersion
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.utility_meter import max_28_days
from homeassistant.components.utility_meter.const import METER_TYPES
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import (
CONF_DOMAIN,
CONF_ENABLED,
EVENT_HOMEASSISTANT_STARTED,
Platform,
__version__ as HA_VERSION, # noqa: N812
)
from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.entity_platform import async_get_platforms
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from .analytics.analytics import ANALYTICS_INTERVAL, Analytics
from .common import validate_name_pattern
from .configuration.global_config import FLAG_HAS_GLOBAL_GUI_CONFIG, get_global_configuration, get_global_gui_configuration
from .const import (
CONF_CREATE_DOMAIN_GROUPS,
CONF_CREATE_ENERGY_SENSORS,
CONF_CREATE_STANDBY_GROUP,
CONF_CREATE_UTILITY_METERS,
CONF_DISABLE_EXTENDED_ATTRIBUTES,
CONF_DISABLE_LIBRARY_DOWNLOAD,
CONF_DISCOVERY,
CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED,
CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED,
CONF_ENABLE_ANALYTICS,
CONF_ENABLE_AUTODISCOVERY_DEPRECATED,
CONF_ENERGY_INTEGRATION_METHOD,
CONF_ENERGY_SENSOR_CATEGORY,
CONF_ENERGY_SENSOR_FRIENDLY_NAMING,
CONF_ENERGY_SENSOR_NAMING,
CONF_ENERGY_SENSOR_PRECISION,
CONF_ENERGY_SENSOR_UNIT_PREFIX,
CONF_ENERGY_UPDATE_INTERVAL,
CONF_EXCLUDE_DEVICE_TYPES,
CONF_EXCLUDE_SELF_USAGE,
CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED,
CONF_GROUP_ENERGY_UPDATE_INTERVAL,
CONF_GROUP_POWER_UPDATE_INTERVAL,
CONF_GROUP_UPDATE_INTERVAL_DEPRECATED,
CONF_IGNORE_UNAVAILABLE_STATE,
CONF_INCLUDE,
CONF_INCLUDE_NON_POWERCALC_SENSORS,
CONF_POWER_SENSOR_CATEGORY,
CONF_POWER_SENSOR_FRIENDLY_NAMING,
CONF_POWER_SENSOR_NAMING,
CONF_POWER_SENSOR_PRECISION,
CONF_POWER_UPDATE_INTERVAL,
CONF_SENSOR_TYPE,
CONF_SENSORS,
CONF_UNAVAILABLE_POWER,
CONF_UTILITY_METER_OFFSET,
CONF_UTILITY_METER_TARIFFS,
CONF_UTILITY_METER_TYPES,
DATA_ANALYTICS,
DATA_CONFIGURED_ENTITIES,
DATA_DISCOVERY_MANAGER,
DATA_DOMAIN_ENTITIES,
DATA_ENTITIES,
DATA_GROUP_ENTITIES,
DATA_STANDBY_POWER_SENSORS,
DATA_USED_UNIQUE_IDS,
DISCOVERY_TYPE,
DOMAIN,
DOMAIN_CONFIG,
ENERGY_INTEGRATION_METHODS,
ENTITY_CATEGORIES,
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
MIN_HA_VERSION,
SERVICE_CHANGE_GUI_CONFIGURATION,
SERVICE_RELOAD,
SERVICE_UPDATE_LIBRARY,
PowercalcDiscoveryType,
SensorType,
UnitPrefix,
)
from .discovery import DiscoveryManager, DiscoveryStatus
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,
remove_power_sensor_from_associated_groups,
)
from .service.gui_configuration import SERVICE_SCHEMA, change_gui_configuration
PLATFORMS = [Platform.SENSOR, Platform.SELECT]
DISCOVERY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ENABLED): cv.boolean,
vol.Optional(CONF_EXCLUDE_DEVICE_TYPES): vol.All(
cv.ensure_list,
[cls.value for cls in DeviceType],
),
vol.Optional(CONF_EXCLUDE_SELF_USAGE): cv.boolean,
},
)
CONFIG_SCHEMA = vol.Schema(
{
vol.Optional(DOMAIN, default=dict): vol.All(
cv.deprecated(CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED),
cv.deprecated(CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED),
cv.deprecated(CONF_ENABLE_AUTODISCOVERY_DEPRECATED),
cv.deprecated(CONF_GROUP_UPDATE_INTERVAL_DEPRECATED),
cv.deprecated(CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED),
vol.Schema(
{
vol.Optional(CONF_ENABLE_ANALYTICS): cv.boolean,
vol.Optional(
CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED,
): cv.time_period,
vol.Optional(CONF_GROUP_UPDATE_INTERVAL_DEPRECATED): cv.positive_int,
vol.Optional(CONF_GROUP_POWER_UPDATE_INTERVAL): cv.positive_int,
vol.Optional(CONF_GROUP_ENERGY_UPDATE_INTERVAL): cv.positive_int,
vol.Optional(CONF_ENERGY_UPDATE_INTERVAL): cv.positive_int,
vol.Optional(CONF_POWER_UPDATE_INTERVAL): cv.positive_int,
vol.Optional(CONF_POWER_SENSOR_NAMING): validate_name_pattern,
vol.Optional(CONF_POWER_SENSOR_FRIENDLY_NAMING): validate_name_pattern,
vol.Optional(CONF_POWER_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES),
vol.Optional(CONF_ENERGY_SENSOR_NAMING): validate_name_pattern,
vol.Optional(CONF_ENERGY_SENSOR_FRIENDLY_NAMING): validate_name_pattern,
vol.Optional(CONF_ENERGY_SENSOR_CATEGORY): vol.In(ENTITY_CATEGORIES),
vol.Optional(CONF_DISABLE_EXTENDED_ATTRIBUTES): cv.boolean,
vol.Optional(CONF_DISABLE_LIBRARY_DOWNLOAD): cv.boolean,
vol.Optional(CONF_DISCOVERY): DISCOVERY_SCHEMA,
vol.Optional(CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED): vol.All(
cv.ensure_list,
[cls.value for cls in DeviceType],
),
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_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)]),
vol.Optional(
CONF_UTILITY_METER_OFFSET,
): vol.All(cv.time_period, cv.positive_timedelta, max_28_days),
vol.Optional(CONF_ENERGY_INTEGRATION_METHOD): vol.In(ENERGY_INTEGRATION_METHODS),
vol.Optional(CONF_ENERGY_SENSOR_PRECISION): cv.positive_int,
vol.Optional(CONF_POWER_SENSOR_PRECISION): cv.positive_int,
vol.Optional(CONF_ENERGY_SENSOR_UNIT_PREFIX): vol.In([cls.value for cls in UnitPrefix]),
vol.Optional(CONF_CREATE_DOMAIN_GROUPS): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_IGNORE_UNAVAILABLE_STATE): cv.boolean,
vol.Optional(CONF_UNAVAILABLE_POWER): vol.Coerce(float),
vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [SENSOR_CONFIG]),
vol.Optional(CONF_INCLUDE_NON_POWERCALC_SENSORS): cv.boolean,
vol.Optional(CONF_CREATE_STANDBY_GROUP): cv.boolean,
},
),
),
},
extra=vol.ALLOW_EXTRA,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if AwesomeVersion(HA_VERSION) < AwesomeVersion(MIN_HA_VERSION): # pragma: no cover
msg = (
"This integration requires at least HomeAssistant version "
f" {MIN_HA_VERSION}, you are running version {HA_VERSION}."
" Please upgrade HomeAssistant to continue use this integration."
)
_notify_message(hass, "inv_ha_version", "PowerCalc", msg)
_LOGGER.critical(msg)
return False
global_config = await get_global_configuration(hass, config)
discovery_manager = await create_discovery_manager_instance(hass, config, global_config)
hass.data[DOMAIN] = {
DATA_DISCOVERY_MANAGER: discovery_manager,
DOMAIN_CONFIG: global_config,
DATA_CONFIGURED_ENTITIES: {},
DATA_DOMAIN_ENTITIES: {},
DATA_GROUP_ENTITIES: {},
DATA_ENTITIES: {},
DATA_USED_UNIQUE_IDS: [],
DATA_STANDBY_POWER_SENSORS: {},
DATA_ANALYTICS: {},
}
await register_services(hass)
await async_load_platform(hass, Platform.SELECT, DOMAIN, {}, config)
await setup_yaml_sensors(hass, config, global_config)
setup_domain_groups(hass, global_config)
setup_standby_group(hass, global_config)
try:
await repair_none_config_entries_issue(hass)
except Exception as e: # pragma: no cover
_LOGGER.error("problem while cleaning up None entities", exc_info=e) # pragma: no cover
await init_analytics(hass)
return True
async def init_analytics(hass: HomeAssistant) -> None:
"""Initialize the Analytics manager and schedule daily submission"""
analytics = Analytics(hass)
await analytics.load()
@callback
def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event."""
async_call_later(
hass,
600,
HassJob(
analytics.send_analytics,
name="powercalc analytics startup",
cancel_on_shutdown=True,
),
)
async_track_time_interval(
hass,
analytics.send_analytics,
ANALYTICS_INTERVAL,
name="powercalc analytics daily",
cancel_on_shutdown=True,
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
async def create_discovery_manager_instance(
hass: HomeAssistant,
ha_config: ConfigType,
global_powercalc_config: ConfigType,
) -> DiscoveryManager:
discovery_config = global_powercalc_config.get(CONF_DISCOVERY, {})
exclude_device_types = [DeviceType(device_type) for device_type in discovery_config.get(CONF_EXCLUDE_DEVICE_TYPES, [])]
exclude_self_usage = discovery_config.get(CONF_EXCLUDE_SELF_USAGE, False)
enable_autodiscovery = discovery_config.get(CONF_ENABLED, True)
manager = DiscoveryManager(
hass,
ha_config,
exclude_device_types=exclude_device_types,
exclude_self_usage_profiles=exclude_self_usage,
enabled=enable_autodiscovery,
)
await manager.setup()
return manager
async def register_services(hass: HomeAssistant) -> None:
"""Register generic services"""
async def _handle_change_gui_service(call: ServiceCall) -> None:
await change_gui_configuration(hass, call)
hass.services.async_register(
DOMAIN,
SERVICE_CHANGE_GUI_CONFIGURATION,
_handle_change_gui_service,
schema=SERVICE_SCHEMA,
)
async def _handle_update_library_service(_: ServiceCall) -> None:
_LOGGER.info("Updating library and rediscovering devices")
discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER]
await discovery_manager.update_library_and_rediscover()
hass.services.async_register(
DOMAIN,
SERVICE_UPDATE_LIBRARY,
_handle_update_library_service,
)
async def _reload_config(_: ServiceCall) -> None:
"""Reload powercalc."""
reload_config = await async_integration_yaml_config(hass, DOMAIN)
reset_platforms = async_get_platforms(hass, DOMAIN)
for reset_platform in reset_platforms:
await reset_platform.async_reset()
if not reload_config:
return # pragma: nocover
hass.data[DOMAIN][DATA_USED_UNIQUE_IDS] = []
hass.data[DOMAIN][DATA_CONFIGURED_ENTITIES] = {}
hass.data[DOMAIN][DATA_ANALYTICS] = {}
hass.data[DOMAIN][DOMAIN_CONFIG] = await get_global_configuration(hass, reload_config)
# Reload YAML sensors if any
if DOMAIN in reload_config:
for sensor_config in reload_config[DOMAIN].get(CONF_SENSORS, []):
sensor_config.update({DISCOVERY_TYPE: PowercalcDiscoveryType.USER_YAML})
await async_load_platform(
hass,
Platform.SENSOR,
DOMAIN,
sensor_config,
reload_config,
)
# Reload all config entries
for entry in hass.config_entries.async_entries(DOMAIN):
_LOGGER.debug("Reloading config entry %s", entry.entry_id)
await hass.config_entries.async_reload(entry.entry_id)
global_config = await get_global_configuration(hass, reload_config)
setup_domain_groups(hass, global_config)
await create_standby_group(hass, global_config)
hass.services.async_register(
DOMAIN,
SERVICE_RELOAD,
_reload_config,
)
async def create_standby_group(
hass: HomeAssistant,
domain_config: ConfigType,
event: Event[Any] | None = None,
) -> None:
if not bool(domain_config.get(CONF_CREATE_STANDBY_GROUP, True)):
return
hass.async_create_task(
async_load_platform(
hass,
SENSOR_DOMAIN,
DOMAIN,
{DISCOVERY_TYPE: PowercalcDiscoveryType.STANDBY_GROUP},
domain_config,
),
)
def setup_standby_group(hass: HomeAssistant, domain_config: ConfigType) -> None:
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STARTED,
partial(create_standby_group, hass, domain_config),
)
def setup_domain_groups(hass: HomeAssistant, global_config: ConfigType) -> None:
domain_groups: list[str] | None = global_config.get(CONF_CREATE_DOMAIN_GROUPS)
if not domain_groups:
return
_LOGGER.debug("Setting up domain based group sensors..")
for domain in domain_groups:
hass.async_create_task(
async_load_platform(
hass,
SENSOR_DOMAIN,
DOMAIN,
{
DISCOVERY_TYPE: PowercalcDiscoveryType.DOMAIN_GROUP,
CONF_DOMAIN: domain,
},
global_config,
),
)
async def setup_yaml_sensors(
hass: HomeAssistant,
config: ConfigType,
domain_config: ConfigType,
) -> None:
sensors: list = domain_config.get(CONF_SENSORS, [])
primary_sensors = []
secondary_sensors = []
for sensor_config in sensors:
sensor_config.update({DISCOVERY_TYPE: PowercalcDiscoveryType.USER_YAML})
if CONF_INCLUDE in sensor_config:
secondary_sensors.append(sensor_config)
else:
primary_sensors.append(sensor_config)
async def _load_secondary_sensors(_: None) -> None:
"""Load secondary sensors after primary sensors."""
await asyncio.gather(
*(
hass.async_create_task(
async_load_platform(
hass,
Platform.SENSOR,
DOMAIN,
sensor_config,
config,
),
)
for sensor_config in secondary_sensors
),
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _load_secondary_sensors)
await asyncio.gather(
*(
hass.async_create_task(
async_load_platform(
hass,
Platform.SENSOR,
DOMAIN,
sensor_config,
config,
),
)
for sensor_config in primary_sensors
),
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Powercalc integration from a config entry."""
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))
# Check if this is the initial creation of the global configuration entry
# If so, update the global configuration with the GUI configuration
# When the flag is set, the global configuration has already been applied during async_setup
if entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID:
global_config = hass.data[DOMAIN][DOMAIN_CONFIG]
if global_config.get(FLAG_HAS_GLOBAL_GUI_CONFIG, False) is False:
await apply_global_gui_configuration_changes(hass, entry)
discovery_enabled = bool(entry.data.get(CONF_DISCOVERY, {}).get(CONF_ENABLED, False))
discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER]
if discovery_enabled and discovery_manager.status == DiscoveryStatus.DISABLED:
_LOGGER.debug("Enabling discovery manager based on global configuration")
discovery_manager.enable()
await discovery_manager.setup()
if not discovery_enabled and discovery_manager.status != DiscoveryStatus.DISABLED:
_LOGGER.debug("Disabling discovery manager based on global configuration")
await discovery_manager.disable()
return True
async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update a given config entry."""
if entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID:
await apply_global_gui_configuration_changes(hass, entry)
await hass.config_entries.async_reload(entry.entry_id)
# Also reload all "parent" groups referring this group as a subgroup
for related_entry in get_entries_having_subgroup(hass, entry):
await hass.config_entries.async_reload(related_entry.entry_id)
async def apply_global_gui_configuration_changes(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Apply global configuration changes to all entities."""
global_config = hass.data[DOMAIN][DOMAIN_CONFIG]
global_config.update(get_global_gui_configuration(entry))
for entry in get_entries_excluding_global_config(hass):
if entry.state != ConfigEntryState.LOADED: # pragma: no cover
continue
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry,
PLATFORMS,
)
if unload_ok:
used_unique_ids: list[str] = hass.data[DOMAIN][DATA_USED_UNIQUE_IDS]
try:
if config_entry.unique_id:
used_unique_ids.remove(config_entry.unique_id)
except ValueError:
return True
return unload_ok
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.remove_initialized_flow(config_entry)
updated_entries: list[ConfigEntry] = []
sensor_type = config_entry.data.get(CONF_SENSOR_TYPE)
if sensor_type == SensorType.VIRTUAL_POWER:
updated_entries = await remove_power_sensor_from_associated_groups(
hass,
config_entry,
)
for entry in updated_entries:
if entry.state == ConfigEntryState.LOADED:
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
await async_migrate_config_entry(hass, config_entry)
return True
async def repair_none_config_entries_issue(hass: HomeAssistant) -> None:
"""Repair issue with config entries having None as data."""
entity_registry = er.async_get(hass)
entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.title == "None"]
for entry in entries:
_LOGGER.debug("Removing entry %s with None data", entry.entry_id)
entities = entity_registry.entities.get_entries_for_config_entry_id(entry.entry_id)
for entity in entities:
entity_registry.async_remove(entity.entity_id)
try:
unique_id = f"{int(time.time() * 1000)}_{random.randint(1000, 9999)}" # noqa: S311
object.__setattr__(entry, "unique_id", unique_id)
hass.config_entries._entries._index_entry(entry) # noqa
await hass.config_entries.async_remove(entry.entry_id)
except Exception as e: # pragma: no cover
_LOGGER.error("problem while cleaning up None entities", exc_info=e) # pragma: no cover
def _notify_message(
hass: HomeAssistant,
notification_id: str,
title: str,
message: str,
) -> None: # pragma: no cover
"""Notify user with persistent notification."""
hass.async_create_task(
hass.services.async_call(
domain="persistent_notification",
service="create",
service_data={
"title": title,
"message": message,
"notification_id": f"{DOMAIN}.{notification_id}",
},
),
)
@@ -0,0 +1,248 @@
from __future__ import annotations
from asyncio import timeout
from collections import Counter
from collections.abc import Hashable
from dataclasses import asdict, dataclass
from datetime import UTC, datetime, timedelta
from itertools import chain
import logging
from typing import Any, Literal, TypedDict
import uuid
import aiohttp
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import __version__ as HA_VERSION # noqa
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.storage import Store
from homeassistant.loader import async_get_integration
from custom_components.powercalc.const import (
API_URL,
CONF_ENABLE_ANALYTICS,
DATA_ANALYTICS,
DATA_CONFIG_TYPES,
DATA_ENTITY_TYPES,
DATA_GROUP_SIZES,
DATA_GROUP_TYPES,
DATA_HAS_GROUP_INCLUDE,
DATA_POWER_PROFILE_SOURCES,
DATA_POWER_PROFILES,
DATA_SENSOR_TYPES,
DATA_SOURCE_DOMAINS,
DATA_STRATEGIES,
DOMAIN,
DOMAIN_CONFIG,
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
CalculationStrategy,
EntityType,
GroupType,
PowerProfileSource,
SensorType,
)
from custom_components.powercalc.power_profile.library import ProfileLibrary
from custom_components.powercalc.power_profile.power_profile import PowerProfile
from custom_components.powercalc.sensors.group.config_entry_utils import get_entries_excluding_global_config
ENDPOINT_ANALYTICS = f"{API_URL}/analytics"
ANALYTICS_INTERVAL = timedelta(days=1)
STORAGE_KEY = "powercalc.analytics"
STORAGE_VERSION = 1
_LOGGER = logging.getLogger(__name__)
_SEEN_KEY: Literal["_seen"] = "_seen"
class RuntimeAnalyticsData(TypedDict, total=False):
sensor_types: Counter[SensorType]
config_types: Counter[str]
strategies: Counter[CalculationStrategy]
power_profiles: list[PowerProfile]
source_domains: Counter[str]
group_types: Counter[GroupType]
group_sizes: list[int]
power_profile_sources: Counter[PowerProfileSource]
entity_types: Counter[EntityType]
uses_include: bool
_seen: dict[str, set[str]]
@dataclass
class AnalyticsData:
"""Analytics data."""
install_id: str | None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AnalyticsData:
"""Initialize analytics data from a dict."""
return cls(
data["install_id"],
)
def collect_analytics(
hass: HomeAssistant,
config_entry: ConfigEntry | None = None,
) -> AnalyticsCollector:
"""Return analytics collector instance"""
analytics_data: RuntimeAnalyticsData = hass.data[DOMAIN][DATA_ANALYTICS]
return AnalyticsCollector(analytics_data, config_entry)
class AnalyticsCollector:
def __init__(
self,
data: RuntimeAnalyticsData,
config_entry: ConfigEntry | None,
) -> None:
self._data = data
self._entry_id = config_entry.entry_id if config_entry else None
self._seen: dict[str, set[str]] = data.setdefault(_SEEN_KEY, {})
def _already_seen(self, key: str) -> bool:
"""Check whether we already collected analytics for a give config entry"""
if not self._entry_id:
return False
seen_for_key = self._seen.setdefault(key, set())
if self._entry_id in seen_for_key:
return True
seen_for_key.add(self._entry_id)
return False
def inc(self, key: str, value: Hashable) -> None:
"""Increment counter"""
if self._already_seen(key):
return
counter: Counter[Hashable] = self._data.setdefault(key, Counter()) # type:ignore
counter[value] += 1
def add(self, key: str, value: Any) -> None: # noqa: ANN401
"""Add value to listing"""
if self._already_seen(key) or value is None:
return
lst: list[Any] = self._data.setdefault(key, []) # type:ignore
lst.append(value)
def set_flag(self, key: str) -> None:
"""Set a boolean flag to True"""
self._data[key] = True # type:ignore
class Analytics:
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self.session = async_get_clientsession(hass)
self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
self._data: AnalyticsData = AnalyticsData(install_id=None)
async def load(self) -> None:
stored = await self._store.async_load()
if stored:
self._data = AnalyticsData.from_dict(stored)
@property
def install_id(self) -> str | None:
return self._data.install_id
async def _prepare_payload(self) -> dict:
powercalc_integration = await async_get_integration(self.hass, DOMAIN)
runtime_data: RuntimeAnalyticsData = self.hass.data[DOMAIN][DATA_ANALYTICS]
power_profiles: list[PowerProfile] = runtime_data.get(DATA_POWER_PROFILES, [])
non_custom_profiles = [profile for profile in power_profiles if not profile.is_custom_profile]
group_sizes: list[int] = runtime_data.get(DATA_GROUP_SIZES, [])
global_config_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN,
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
)
return {
"install_id": self.install_id,
"install_date": await self._get_install_date(),
"powercalc_version": powercalc_integration.version,
"ha_version": HA_VERSION,
"language": self.hass.config.language,
"config_entry_count": len(get_entries_excluding_global_config(self.hass)),
"custom_profile_count": await self._get_custom_profile_count(),
"has_global_gui_config": global_config_entry is not None,
"has_group_include": runtime_data.get(DATA_HAS_GROUP_INCLUDE, False),
"group_sizes": Counter(group_sizes),
"counts": {
"by_config_type": runtime_data.setdefault(DATA_CONFIG_TYPES, Counter()),
"by_device_type": Counter(profile.device_type for profile in power_profiles),
"by_sensor_type": runtime_data.setdefault(DATA_SENSOR_TYPES, Counter()),
"by_manufacturer": Counter(profile.manufacturer for profile in non_custom_profiles),
"by_model": Counter(f"{profile.manufacturer}:{profile.model}" for profile in non_custom_profiles),
"by_strategy": runtime_data.setdefault(DATA_STRATEGIES, Counter()),
"by_source_domain": runtime_data.setdefault(DATA_SOURCE_DOMAINS, Counter()),
"by_group_type": runtime_data.setdefault(DATA_GROUP_TYPES, Counter()),
"by_entity_type": runtime_data.setdefault(DATA_ENTITY_TYPES, Counter()),
"by_power_profile_source": runtime_data.setdefault(DATA_POWER_PROFILE_SOURCES, Counter()),
},
}
async def _get_install_date(self) -> str | None:
"""
Get the install date based on the earliest created_at date of config entries and entities.
"""
cutoff = datetime(2000, 1, 1, tzinfo=UTC)
dates = chain(
(e.created_at for e in self.hass.config_entries.async_entries(DOMAIN) if e.created_at > cutoff),
(e.created_at for e in entity_registry.async_get(self.hass).entities.values() if e.domain != DOMAIN and e.created_at > cutoff),
)
try:
install_date = min(dates)
return install_date.isoformat(timespec="seconds").replace("+00:00", "Z")
except ValueError:
return None
async def _get_custom_profile_count(self) -> int:
loader = ProfileLibrary.create_loader(self.hass, True)
await loader.initialize()
total = 0
for manufacturer in await loader.get_manufacturer_listing(None):
total += len(await loader.get_model_listing(manufacturer[0], None))
return total
async def send_analytics(self, _: datetime | None = None) -> None:
"""Send analytics."""
global_config = self.hass.data.get(DOMAIN, {}).get(DOMAIN_CONFIG, {})
if not global_config.get(CONF_ENABLE_ANALYTICS, False):
_LOGGER.debug("Analytics is disabled in global configuration")
return
if self._data.install_id is None:
self._data.install_id = str(uuid.uuid4())
await self._store.async_save(asdict(self._data))
payload = await self._prepare_payload()
try:
async with timeout(30):
response = await self.session.post(ENDPOINT_ANALYTICS, json=payload)
if 200 <= response.status < 300:
_LOGGER.info(
("Submitted Powercalc analytics. Information submitted includes %s"),
payload,
)
else:
_LOGGER.warning(
"Sending analytics failed with statuscode %s from %s",
response.status,
ENDPOINT_ANALYTICS,
)
except TimeoutError:
_LOGGER.error("Timeout sending analytics to %s", ENDPOINT_ANALYTICS)
except aiohttp.ClientError as err:
_LOGGER.error(
"Error sending analytics to %s: %r",
ENDPOINT_ANALYTICS,
err,
)
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
import math
import re
from typing import NamedTuple
from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant, split_entity_id
import homeassistant.helpers.device_registry as dr
import homeassistant.helpers.entity_registry as er
import voluptuous as vol
from .const import (
CONF_CREATE_ENERGY_SENSOR,
CONF_CREATE_ENERGY_SENSORS,
CONF_CREATE_GROUP,
CONF_DAILY_FIXED_ENERGY,
CONF_FORCE_ENERGY_SENSOR_CREATION,
CONF_MULTI_SWITCH,
CONF_POWER_SENSOR_ID,
CONF_SENSOR_TYPE,
DUMMY_ENTITY_ID,
SensorType,
)
from .errors import SensorConfigurationError
class SourceEntity(NamedTuple):
object_id: str
entity_id: str
domain: str
unique_id: str | None = None
name: str | None = None
supported_color_modes: list[ColorMode] | None = None
entity_entry: er.RegistryEntry | None = None
device_entry: dr.DeviceEntry | None = None
def is_number(value: str) -> bool:
"""Return whether the value can be converted to a finite float."""
try:
fvalue = float(value)
except (TypeError, ValueError):
return False
return math.isfinite(fvalue)
async def create_source_entity(entity_id: str, hass: HomeAssistant) -> SourceEntity:
"""Create object containing all information about the source entity."""
source_entity_domain, source_object_id = split_entity_id(entity_id)
if entity_id == DUMMY_ENTITY_ID:
return SourceEntity(
object_id=source_object_id,
entity_id=DUMMY_ENTITY_ID,
domain=source_entity_domain,
)
entity_registry = er.async_get(hass)
entity_entry = entity_registry.async_get(entity_id)
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(entity_entry.device_id) if entity_entry and entity_entry.device_id else None
unique_id = None
supported_color_modes: list[ColorMode] = []
if entity_entry:
source_entity_domain = entity_entry.domain
unique_id = entity_entry.unique_id
if entity_entry.capabilities:
supported_color_modes = entity_entry.capabilities.get( # type: ignore[assignment]
ATTR_SUPPORTED_COLOR_MODES,
)
entity_state = hass.states.get(entity_id)
if entity_state:
supported_color_modes = entity_state.attributes.get(ATTR_SUPPORTED_COLOR_MODES)
return SourceEntity(
source_object_id,
entity_id,
source_entity_domain,
unique_id,
get_wrapped_entity_name(
hass,
entity_id,
source_object_id,
entity_entry,
device_entry,
),
supported_color_modes or [],
entity_entry,
device_entry,
)
def get_wrapped_entity_name(
hass: HomeAssistant,
entity_id: str,
object_id: str,
entity_entry: er.RegistryEntry | None,
device_entry: dr.DeviceEntry | None,
) -> str:
"""Construct entity name based on the wrapped entity"""
if entity_entry:
if entity_entry.name:
return entity_entry.name
if entity_entry.has_entity_name and device_entry:
device_name = device_entry.name_by_user or device_entry.name
if device_name:
return f"{device_name} {entity_entry.original_name}" if entity_entry.original_name else device_name
return entity_entry.original_name or object_id
entity_state = hass.states.get(entity_id)
if entity_state:
return str(entity_state.name)
return object_id
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,
]
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)
merged_config.update(config_copy)
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))
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:
raise SensorConfigurationError(
"You must supply an entity_id in the configuration, see the README",
)
return merged_config
def validate_name_pattern(value: str) -> str:
"""Validate that the naming pattern contains {}."""
regex = re.compile(r"{}")
if not regex.search(value):
raise vol.Invalid("Naming pattern must contain {}")
return value
def validate_is_number(value: str) -> str:
"""Validate value is a number."""
if is_number(value):
return value
raise vol.Invalid("Value is not a number")
+640
View File
@@ -0,0 +1,640 @@
"""Config flow for Powercalc integration."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Callable
import logging
from typing import Any, cast
import uuid
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import (
CONF_DEVICE,
CONF_ENTITY_ID,
CONF_NAME,
CONF_UNIQUE_ID,
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
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
import voluptuous as vol
from .common import SourceEntity, create_source_entity
from .const import (
CONF_CREATE_UTILITY_METERS,
CONF_MANUFACTURER,
CONF_MODE,
CONF_MODEL,
CONF_POWER,
CONF_SENSOR_TYPE,
CONF_STANDBY_POWER,
DISCOVERY_POWER_PROFILES,
DISCOVERY_SOURCE_ENTITY,
DOMAIN,
DUMMY_ENTITY_ID,
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
CalculationStrategy,
SensorType,
)
from .errors import ModelNotSupportedError, StrategyConfigurationError
from .flow_helper.common import FlowType, PowercalcFormStep, Step, fill_schema_defaults
from .flow_helper.flows.daily_energy import SCHEMA_DAILY_ENERGY_OPTIONS, DailyEnergyConfigFlow, DailyEnergyOptionsFlow, build_daily_energy_config
from .flow_helper.flows.global_configuration import (
GlobalConfigurationConfigFlow,
GlobalConfigurationOptionsFlow,
get_global_powercalc_config,
)
from .flow_helper.flows.group import (
GroupConfigFlow,
GroupOptionsFlow,
)
from .flow_helper.flows.library import SCHEMA_POWER_SMART_SWITCH, LibraryConfigFlow, LibraryOptionsFlow
from .flow_helper.flows.real_power import RealPowerConfigFlow, RealPowerOptionsFlow
from .flow_helper.flows.virtual_power import (
SCHEMA_POWER_ADVANCED,
STRATEGY_STEP_MAPPING,
VirtualPowerConfigFlow,
VirtualPowerOptionsFlow,
)
from .flow_helper.schema import (
SCHEMA_ENERGY_SENSOR_TOGGLE,
SCHEMA_SENSOR_ENERGY_OPTIONS,
SCHEMA_UTILITY_METER_OPTIONS,
SCHEMA_UTILITY_METER_TOGGLE,
)
from .power_profile.factory import get_power_profile
from .power_profile.power_profile import SUPPORTED_DOMAINS, DeviceType, DiscoveryBy, PowerProfile
from .strategy.factory import PowerCalculatorStrategyFactory
_LOGGER = logging.getLogger(__name__)
MENU_SENSOR_TYPE = [
Step.VIRTUAL_POWER,
Step.MENU_LIBRARY,
Step.MENU_GROUP,
Step.DAILY_ENERGY,
Step.REAL_POWER,
]
MENU_OPTIONS = [
Step.FIXED,
Step.LINEAR,
Step.MULTI_SWITCH,
Step.PLAYBOOK,
Step.WLED,
]
FLOW_HANDLERS: dict[FlowType, dict] = {
FlowType.GROUP: {
"config": GroupConfigFlow,
"options": GroupOptionsFlow,
},
FlowType.DAILY_ENERGY: {
"config": DailyEnergyConfigFlow,
"options": DailyEnergyOptionsFlow,
},
FlowType.LIBRARY: {
"config": LibraryConfigFlow,
"options": LibraryOptionsFlow,
},
FlowType.VIRTUAL_POWER: {
"config": VirtualPowerConfigFlow,
"options": VirtualPowerOptionsFlow,
},
FlowType.GLOBAL_CONFIGURATION: {
"config": GlobalConfigurationConfigFlow,
"options": GlobalConfigurationOptionsFlow,
},
FlowType.REAL_POWER: {
"config": RealPowerConfigFlow,
"options": RealPowerOptionsFlow,
},
}
# noinspection PyTypeChecker
class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow):
def __init__(self) -> None:
"""Initialize options flow."""
self.sensor_config: ConfigType = {}
self.global_config: ConfigType = {}
self.source_entity: SourceEntity | None = None
self.source_entity_id: str | None = None
self.selected_profile: PowerProfile | None = None
self.selected_sub_profile: str | None = None
self.is_library_flow: bool = False
self.skip_advanced_step: bool = False
self.selected_sensor_type: SensorType | None = None
self.is_options_flow: bool = isinstance(self, OptionsFlow)
self.strategy: CalculationStrategy | None = None
self.name: str | None = None
self.handled_steps: list[Step] = []
# Initialize flow handlers
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),
}
for step in Step:
step_method = f"async_step_{step}"
if hasattr(self, step_method):
continue
setattr(self, step_method, self._async_step(step))
super().__init__()
@abstractmethod
@callback
def persist_config_entry(self) -> FlowResult:
pass # pragma: no cover
def _async_step(self, step: Step) -> Callable:
"""Generate a step handler."""
async def _async_step(
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle a config flow step."""
return await self.async_step(step, user_input)
return _async_step
async def async_step(self, step: Step, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle a config flow step by delegating to specific handler."""
step_method = f"async_step_{step}"
for handler in self.flow_handlers.values():
if hasattr(handler, step_method):
return await getattr(handler, step_method)(user_input) # type:ignore
raise SchemaFlowError("No handler defined") # pragma: nocover
async def validate_strategy_config(self, user_input: dict[str, Any] | None = None) -> None:
"""Validate the strategy config."""
strategy_name = CalculationStrategy(
self.sensor_config.get(CONF_MODE) or self.selected_profile.calculation_strategy, # type: ignore
)
factory = PowerCalculatorStrategyFactory(self.hass)
try:
await factory.create(user_input or self.sensor_config, strategy_name, self.selected_profile, self.source_entity) # type: ignore
except StrategyConfigurationError as error:
_LOGGER.error(str(error))
raise SchemaFlowError(error.get_config_flow_translate_key() or "unknown") from error
def create_source_entity_selector(
self,
) -> selector.EntitySelector:
"""Create the entity selector for the source entity."""
if self.is_library_flow:
return selector.EntitySelector(
selector.EntitySelectorConfig(domain=list(SUPPORTED_DOMAINS)),
)
return selector.EntitySelector()
def create_device_entity_selector(self, domains: list[str], multiple: bool = False) -> selector.EntitySelector:
entity_registry = er.async_get(self.hass)
if self.source_entity and self.source_entity.device_entry:
entities = [
entity.entity_id
for entity in entity_registry.entities.get_entries_for_device_id(self.source_entity.device_entry.id)
if entity.domain in domains
]
else:
entities = []
return selector.EntitySelector(
selector.EntitySelectorConfig(
domain=domains,
multiple=multiple,
include_entities=entities,
),
)
async def handle_form_step(
self,
form_step: PowercalcFormStep,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle the current step."""
if user_input is not None:
if form_step.validate_user_input is not None:
try:
user_input = await form_step.validate_user_input(user_input)
except SchemaFlowError as exc:
return await self._show_form(form_step, exc)
if CONF_NAME in user_input:
self.name = user_input[CONF_NAME]
self.sensor_config.update(user_input)
self.handled_steps.append(form_step.step)
next_step = form_step.next_step
if callable(form_step.next_step):
next_step = await form_step.next_step(user_input)
if not next_step:
return await self.handle_final_steps(
skip_advanced=not form_step.continue_advanced_step,
skip_utility_meter_options=not form_step.continue_utility_meter_options_step,
)
return await getattr(self, f"async_step_{next_step}")() # type: ignore
return await self._show_form(form_step)
async def handle_final_steps(
self,
skip_advanced: bool = False,
skip_utility_meter_options: bool = False,
) -> FlowResult:
"""Handle the final steps of the flow if needed and persist the config entry."""
if not skip_advanced and self.selected_sensor_type == SensorType.VIRTUAL_POWER:
return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_power_advanced() # type:ignore
if not skip_utility_meter_options and self.sensor_config.get(CONF_CREATE_UTILITY_METERS):
return await self.async_step_utility_meter_options()
return self.persist_config_entry()
async def _show_form(self, form_step: PowercalcFormStep, error: SchemaFlowError | None = None) -> FlowResult:
# Show form for next step
last_step = None
if not callable(form_step.next_step):
last_step = form_step.next_step is None
schema = await self._get_schema(form_step)
# noinspection PyTypeChecker
form_data = form_step.form_data
if form_data is None:
form_data = {**self.sensor_config, **get_global_powercalc_config(self)}
return self.async_show_form(
step_id=form_step.step,
data_schema=fill_schema_defaults(schema, form_data),
errors={"base": str(error)} if error else {},
last_step=last_step,
**(form_step.form_kwarg or {}),
)
async def _get_schema(self, form_step: PowercalcFormStep) -> vol.Schema:
if isinstance(form_step.schema, vol.Schema):
return form_step.schema
return await form_step.schema()
async def async_step_utility_meter_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for utility meter options."""
return await self.handle_form_step(
PowercalcFormStep(
step=Step.UTILITY_METER_OPTIONS,
schema=SCHEMA_UTILITY_METER_OPTIONS,
),
user_input,
)
async def async_step_energy_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for utility meter options."""
return await self.handle_form_step(
PowercalcFormStep(
step=Step.ENERGY_OPTIONS,
schema=SCHEMA_SENSOR_ENERGY_OPTIONS,
continue_utility_meter_options_step=not self.is_options_flow,
),
user_input,
)
class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN):
"""Handle a config flow for PowerCalc."""
VERSION = 7
def __init__(self) -> None:
"""Initialize options flow."""
self.discovered_profiles: dict[str, PowerProfile] = {}
super().__init__()
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Get the options flow for this handler."""
return PowercalcOptionsFlow(config_entry)
@callback
def abort_if_unique_id_configured(self) -> None:
self._abort_if_unique_id_configured()
async def async_step_integration_discovery(
self,
discovery_info: DiscoveryInfoType,
) -> ConfigFlowResult:
"""Handle integration discovery."""
_LOGGER.debug("Starting discovery flow: %s", discovery_info)
await self.async_set_unique_id(discovery_info.get(CONF_UNIQUE_ID, str(uuid.uuid4())))
self.selected_sensor_type = SensorType.VIRTUAL_POWER
self.source_entity = discovery_info[DISCOVERY_SOURCE_ENTITY]
del discovery_info[DISCOVERY_SOURCE_ENTITY]
if not self.source_entity:
return self.async_abort(reason="No source entity set") # pragma: no cover
self.source_entity_id = self.source_entity.entity_id
self.name = self.source_entity.name
power_profiles: list[PowerProfile] = []
if DISCOVERY_POWER_PROFILES in discovery_info:
power_profiles = discovery_info[DISCOVERY_POWER_PROFILES]
self.discovered_profiles = {profile.unique_id: profile for profile in power_profiles}
del discovery_info[DISCOVERY_POWER_PROFILES]
self.sensor_config = discovery_info.copy()
self.context["title_placeholders"] = {
"name": self.name or "",
"manufacturer": str(self.sensor_config.get(CONF_MANUFACTURER)),
"model": str(self.sensor_config.get(CONF_MODEL)),
}
self.skip_advanced_step = True # We don't want to ask advanced options when discovered
self.is_library_flow = True
if discovery_info.get(CONF_MODE) == CalculationStrategy.WLED:
return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_wled() # type:ignore
if len(power_profiles) > 1:
return cast(ConfigFlowResult, await self.flow_handlers[FlowType.LIBRARY].async_step_library_multi_profile())
self.selected_profile = power_profiles[0]
return cast(ConfigFlowResult, await self.flow_handlers[FlowType.LIBRARY].async_step_library())
async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Start flow when initialized by the user."""
self.is_library_flow = False
global_config_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN,
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
)
menu = MENU_SENSOR_TYPE.copy()
if not global_config_entry:
menu.insert(0, Step.GLOBAL_CONFIGURATION)
await self.async_set_unique_id(str(uuid.uuid4()))
return self.async_show_menu(step_id=Step.USER, menu_options=menu)
async def async_step_menu_library(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the Virtual power (library) step.
We forward to the virtual_power step, but without the strategy selector displayed.
"""
self.is_library_flow = True
return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_virtual_power(user_input) # type:ignore
@callback
def persist_config_entry(self) -> FlowResult:
"""Create the config entry."""
self.sensor_config.update({CONF_SENSOR_TYPE: self.selected_sensor_type})
self.sensor_config.update({CONF_NAME: self.name})
if self.source_entity_id:
self.sensor_config.update({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})
return self.async_create_entry(title=str(self.name), data=self.sensor_config)
class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
"""Handle an option flow for PowerCalc."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
super().__init__()
self.sensor_config = dict(config_entry.data)
self.strategy = self.sensor_config.get(CONF_MODE)
@callback
def abort_if_unique_id_configured(self) -> None:
"""Return if unique_id is already configured."""
# pragma: nocover
async def async_set_unique_id(self, unique_id: str | None, raise_on_progress: bool = True) -> None:
"""Set the unique ID of the flow."""
# pragma: nocover
async def async_step_init(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle options flow."""
self.selected_sensor_type = self.sensor_config.get(CONF_SENSOR_TYPE) or SensorType.VIRTUAL_POWER
self.source_entity_id = self.sensor_config.get(CONF_ENTITY_ID)
if self.config_entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID:
self.global_config = get_global_powercalc_config(self)
return self.async_show_menu(step_id=Step.INIT, menu_options=self.flow_handlers[FlowType.GLOBAL_CONFIGURATION].build_global_config_menu())
self.sensor_config = dict(self.config_entry.data)
if self.source_entity_id:
self.source_entity = await create_source_entity(
self.source_entity_id,
self.hass,
)
result = await self.initialize_library_profile()
if result:
return result
return self.async_show_menu(step_id=Step.INIT, menu_options=self.build_menu())
async def initialize_library_profile(self) -> FlowResult | None:
"""Initialize the library profile, when manufacturer and model are set."""
manufacturer: str | None = self.sensor_config.get(CONF_MANUFACTURER)
model: str | None = self.sensor_config.get(CONF_MODEL)
if not manufacturer or not model:
return None
try:
self.selected_profile = await get_power_profile(
self.hass,
self.sensor_config,
self.source_entity,
process_variables=False,
)
if self.selected_profile and not self.strategy:
self.strategy = self.selected_profile.calculation_strategy
except ModelNotSupportedError:
return self.async_abort(reason="model_not_supported")
return None
def build_menu(self) -> list[Step]:
"""Build the options menu."""
menu = [Step.BASIC_OPTIONS]
if self.selected_sensor_type == SensorType.VIRTUAL_POWER:
if self.strategy and self.should_add_strategy_option_to_menu():
strategy_step = STRATEGY_STEP_MAPPING[self.strategy]
menu.append(strategy_step)
if self.selected_profile:
menu.append(Step.LIBRARY_OPTIONS)
menu.append(Step.ADVANCED_OPTIONS)
if self.selected_sensor_type == SensorType.DAILY_ENERGY:
menu.append(Step.DAILY_ENERGY)
if self.selected_sensor_type == SensorType.REAL_POWER:
menu.extend([Step.REAL_POWER, Step.ENERGY_OPTIONS])
if self.selected_sensor_type == SensorType.GROUP:
menu.extend(self.flow_handlers[FlowType.GROUP].build_group_menu())
if self.sensor_config.get(CONF_CREATE_UTILITY_METERS):
menu.append(Step.UTILITY_METER_OPTIONS)
return menu
def should_add_strategy_option_to_menu(self) -> bool:
"""Check whether the strategy option should be added to the menu."""
if not self.strategy or self.strategy not in STRATEGY_STEP_MAPPING:
return False
if self.selected_profile:
if self.strategy == CalculationStrategy.FIXED and not self.selected_profile.needs_fixed_config:
return False
if self.strategy == CalculationStrategy.LINEAR and not self.selected_profile.needs_linear_config:
return False
return True
async def async_step_basic_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the basic options flow."""
return await self.async_handle_options_step(user_input, self.build_basic_options_schema(), Step.BASIC_OPTIONS)
async def async_step_advanced_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the basic options flow."""
return await self.async_handle_options_step(user_input, SCHEMA_POWER_ADVANCED, Step.ADVANCED_OPTIONS)
async def async_step_utility_meter_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the basic options flow."""
return await self.async_handle_options_step(user_input, SCHEMA_UTILITY_METER_OPTIONS, Step.UTILITY_METER_OPTIONS)
async def async_handle_options_step(self, user_input: dict[str, Any] | None, schema: vol.Schema, step: Step) -> FlowResult:
"""
Generic handler for all the option steps.
processes user input against the select schema.
And finally persist the changes on the config entry
"""
errors: dict[str, str] | None = {}
schema = fill_schema_defaults(schema, self.sensor_config)
if user_input is not None:
errors = await self.process_all_options(user_input, schema)
if not errors:
return self.persist_config_entry()
return self.async_show_form(step_id=step, data_schema=schema, errors=errors)
def persist_config_entry(self) -> FlowResult:
"""Persist changed options on the config entry."""
data = (self.config_entry.unique_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID and self.global_config) or self.sensor_config
self.hass.config_entries.async_update_entry(
self.config_entry,
data=data,
)
return self.async_create_entry(title="", data={})
async def process_all_options(self, user_input: dict[str, Any], schema: vol.Schema) -> dict[str, str] | None:
"""
Process the provided user input against the schema,
and save the options data in current_config to save later on
"""
assert self.cur_step is not None
current_step: Step = Step(str(self.cur_step["step_id"]))
is_strategy_step = current_step in STRATEGY_STEP_MAPPING.values()
if self.strategy and is_strategy_step:
if self.selected_profile and self.selected_profile.device_type == DeviceType.SMART_SWITCH:
self._process_user_input(user_input, SCHEMA_POWER_SMART_SWITCH)
user_input = {CONF_POWER: user_input.get(CONF_POWER, 0)}
strategy_options = await self.flow_handlers[FlowType.VIRTUAL_POWER].build_strategy_config(user_input or {})
if self.strategy != CalculationStrategy.LUT:
self.sensor_config.update({str(self.strategy): strategy_options})
try:
await self.validate_strategy_config()
return None
except SchemaFlowError as exc:
return {"base": str(exc)}
self._process_user_input(user_input, schema)
if self.selected_sensor_type == SensorType.DAILY_ENERGY and current_step == Step.DAILY_ENERGY:
self.sensor_config.update(build_daily_energy_config(user_input, SCHEMA_DAILY_ENERGY_OPTIONS))
if CONF_ENTITY_ID in user_input:
self.sensor_config[CONF_ENTITY_ID] = user_input[CONF_ENTITY_ID]
return None
def _process_user_input(
self,
user_input: dict[str, Any],
schema: vol.Schema,
) -> None:
"""
Process the provided user input against the schema.
Update the current_config dictionary with the new options. We use that to save the data to config entry later on.
"""
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)
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
if self.selected_sensor_type == SensorType.GROUP:
return vol.Schema(
{
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
schema = vol.Schema({})
if self.source_entity_id != DUMMY_ENTITY_ID:
schema = schema.extend(
{vol.Optional(CONF_ENTITY_ID): self.create_source_entity_selector()},
)
if not (self.selected_profile and self.selected_profile.only_self_usage):
schema = schema.extend(
{vol.Optional(CONF_STANDBY_POWER): vol.Coerce(float)},
)
return schema.extend( # type: ignore
{
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
@@ -0,0 +1,116 @@
from datetime import timedelta
import logging
from homeassistant.components.utility_meter import DEFAULT_OFFSET
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENABLED, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from custom_components.powercalc.const import (
CONF_CREATE_DOMAIN_GROUPS,
CONF_CREATE_ENERGY_SENSORS,
CONF_CREATE_STANDBY_GROUP,
CONF_CREATE_UTILITY_METERS,
CONF_DISABLE_EXTENDED_ATTRIBUTES,
CONF_DISCOVERY,
CONF_ENABLE_ANALYTICS,
CONF_ENERGY_INTEGRATION_METHOD,
CONF_ENERGY_SENSOR_CATEGORY,
CONF_ENERGY_SENSOR_NAMING,
CONF_ENERGY_SENSOR_PRECISION,
CONF_ENERGY_SENSOR_UNIT_PREFIX,
CONF_ENERGY_UPDATE_INTERVAL,
CONF_EXCLUDE_DEVICE_TYPES,
CONF_EXCLUDE_SELF_USAGE,
CONF_GROUP_ENERGY_UPDATE_INTERVAL,
CONF_GROUP_POWER_UPDATE_INTERVAL,
CONF_IGNORE_UNAVAILABLE_STATE,
CONF_INCLUDE_NON_POWERCALC_SENSORS,
CONF_POWER_SENSOR_CATEGORY,
CONF_POWER_SENSOR_NAMING,
CONF_POWER_SENSOR_PRECISION,
CONF_UTILITY_METER_OFFSET,
CONF_UTILITY_METER_TYPES,
DEFAULT_ENERGY_INTEGRATION_METHOD,
DEFAULT_ENERGY_NAME_PATTERN,
DEFAULT_ENERGY_SENSOR_PRECISION,
DEFAULT_ENERGY_UNIT_PREFIX,
DEFAULT_ENERGY_UPDATE_INTERVAL,
DEFAULT_ENTITY_CATEGORY,
DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL,
DEFAULT_GROUP_POWER_UPDATE_INTERVAL,
DEFAULT_POWER_NAME_PATTERN,
DEFAULT_POWER_SENSOR_PRECISION,
DEFAULT_UTILITY_METER_TYPES,
DOMAIN,
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
)
from custom_components.powercalc.migrate import handle_legacy_discovery_config, handle_legacy_update_interval_config
FLAG_HAS_GLOBAL_GUI_CONFIG = "has_global_gui_config"
_LOGGER = logging.getLogger(__name__)
async def get_global_configuration(hass: HomeAssistant, config: ConfigType) -> ConfigType:
# Default configuration values
default_config = {
CONF_ENABLE_ANALYTICS: False,
CONF_POWER_SENSOR_NAMING: DEFAULT_POWER_NAME_PATTERN,
CONF_POWER_SENSOR_PRECISION: DEFAULT_POWER_SENSOR_PRECISION,
CONF_POWER_SENSOR_CATEGORY: DEFAULT_ENTITY_CATEGORY,
CONF_ENERGY_INTEGRATION_METHOD: DEFAULT_ENERGY_INTEGRATION_METHOD,
CONF_ENERGY_SENSOR_NAMING: DEFAULT_ENERGY_NAME_PATTERN,
CONF_ENERGY_SENSOR_PRECISION: DEFAULT_ENERGY_SENSOR_PRECISION,
CONF_ENERGY_SENSOR_CATEGORY: DEFAULT_ENTITY_CATEGORY,
CONF_ENERGY_SENSOR_UNIT_PREFIX: DEFAULT_ENERGY_UNIT_PREFIX,
CONF_GROUP_ENERGY_UPDATE_INTERVAL: DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL,
CONF_GROUP_POWER_UPDATE_INTERVAL: DEFAULT_GROUP_POWER_UPDATE_INTERVAL,
CONF_ENERGY_UPDATE_INTERVAL: DEFAULT_ENERGY_UPDATE_INTERVAL,
CONF_DISABLE_EXTENDED_ATTRIBUTES: False,
CONF_IGNORE_UNAVAILABLE_STATE: False,
CONF_CREATE_DOMAIN_GROUPS: [],
CONF_CREATE_ENERGY_SENSORS: True,
CONF_CREATE_STANDBY_GROUP: True,
CONF_CREATE_UTILITY_METERS: False,
CONF_DISCOVERY: {
CONF_ENABLED: True,
CONF_EXCLUDE_SELF_USAGE: False,
CONF_EXCLUDE_DEVICE_TYPES: [],
},
CONF_UTILITY_METER_OFFSET: DEFAULT_OFFSET,
CONF_UTILITY_METER_TYPES: DEFAULT_UTILITY_METER_TYPES,
CONF_INCLUDE_NON_POWERCALC_SENSORS: True,
}
# First load GUI configuration if available
global_config = dict(default_config)
global_config_entry = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, ENTRY_GLOBAL_CONFIG_UNIQUE_ID)
if global_config_entry:
_LOGGER.debug("Found global configuration entry: %s", global_config_entry.data)
global_config.update(get_global_gui_configuration(global_config_entry))
# Then override with YAML configuration if available
yaml_config: dict = config.get(DOMAIN, {})
if yaml_config:
global_config.update(yaml_config)
await handle_legacy_discovery_config(hass, global_config, yaml_config)
await handle_legacy_update_interval_config(hass, global_config, yaml_config)
return global_config
def get_global_gui_configuration(config_entry: ConfigEntry) -> ConfigType:
global_config = dict(config_entry.data)
if CONF_UTILITY_METER_OFFSET in global_config:
global_config[CONF_UTILITY_METER_OFFSET] = timedelta(days=global_config[CONF_UTILITY_METER_OFFSET])
if global_config.get(CONF_ENERGY_SENSOR_CATEGORY):
global_config[CONF_ENERGY_SENSOR_CATEGORY] = EntityCategory(global_config[CONF_ENERGY_SENSOR_CATEGORY])
if global_config.get(CONF_POWER_SENSOR_CATEGORY):
global_config[CONF_POWER_SENSOR_CATEGORY] = EntityCategory(global_config[CONF_POWER_SENSOR_CATEGORY])
global_config[FLAG_HAS_GLOBAL_GUI_CONFIG] = True
return global_config
+313
View File
@@ -0,0 +1,313 @@
"""The Powercalc constants."""
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from homeassistant.components import cover, device_tracker
from homeassistant.components.utility_meter.const import DAILY, MONTHLY, WEEKLY
from homeassistant.const import (
STATE_CLOSED,
STATE_NOT_HOME,
STATE_OFF,
STATE_OPEN,
STATE_STANDBY,
STATE_UNAVAILABLE,
EntityCategory,
)
MIN_HA_VERSION = "2025.1"
BUILT_IN_LIBRARY_DIR = "powercalc_profiles"
DOMAIN = "powercalc"
DOMAIN_CONFIG = "config"
DATA_CONFIGURED_ENTITIES = "configured_entities"
DATA_DISCOVERY_MANAGER = "discovery_manager"
DATA_DOMAIN_ENTITIES = "domain_entities"
DATA_ENTITIES = "entities"
DATA_GROUP_ENTITIES = "group_entities"
DATA_USED_UNIQUE_IDS = "used_unique_ids"
DATA_STANDBY_POWER_SENSORS = "standby_power_sensors"
DATA_ANALYTICS = "analytics"
DATA_ANALYTICS_SEEN_ENTRIES = "analytics_seen_entries"
DATA_POWER_PROFILES: Literal["power_profiles"] = "power_profiles"
DATA_SENSOR_TYPES: Literal["sensor_types"] = "sensor_types"
DATA_CONFIG_TYPES: Literal["config_types"] = "config_types"
DATA_SOURCE_DOMAINS: Literal["source_domains"] = "source_domains"
DATA_GROUP_TYPES: Literal["group_types"] = "group_types"
DATA_ENTITY_TYPES: Literal["entity_types"] = "entity_types"
DATA_STRATEGIES: Literal["strategies"] = "strategies"
DATA_GROUP_SIZES: Literal["group_sizes"] = "group_sizes"
DATA_HAS_GROUP_INCLUDE: Literal["has_group_include"] = "has_group_include"
DATA_POWER_PROFILE_SOURCES: Literal["power_profile_sources"] = "power_profile_sources"
ENTRY_DATA_ENERGY_ENTITY = "_energy_entity"
ENTRY_DATA_POWER_ENTITY = "_power_entity"
ENTRY_GLOBAL_CONFIG_UNIQUE_ID = "powercalc_global_configuration"
PLACEHOLDER_ENTITY_BY_DEVICE_CLASS = "entity_by_device_class:"
PLACEHOLDER_ENTITY_BY_TRANSLATION_KEY = "entity_by_translation_key:"
DUMMY_ENTITY_ID = "sensor.dummy"
CONF_ALL = "all"
CONF_AND = "and"
CONF_ENABLE_ANALYTICS = "enable_analytics"
CONF_AREA = "area"
CONF_AUTOSTART = "autostart"
CONF_AVAILABILITY_ENTITY = "availability_entity"
CONF_CALCULATION_ENABLED_CONDITION = "calculation_enabled_condition"
CONF_CALIBRATE = "calibrate"
CONF_CATEGORY = "category"
CONF_COMPOSITE = "composite"
CONF_CREATE_DOMAIN_GROUPS = "create_domain_groups"
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_UTILITY_METERS = "create_utility_meters"
CONF_CUSTOM_MODEL_DIRECTORY = "custom_model_directory"
CONF_DAILY_FIXED_ENERGY = "daily_fixed_energy"
CONF_DELAY = "delay"
CONF_DISABLE_EXTENDED_ATTRIBUTES = "disable_extended_attributes"
CONF_DISABLE_LIBRARY_DOWNLOAD = "disable_library_download"
CONF_DISABLE_STANDBY_POWER = "disable_standby_power"
CONF_DISCOVERY: str = "discovery"
CONF_EXCLUDE_DEVICE_TYPES = "exclude_device_types"
CONF_EXCLUDE_SELF_USAGE = "exclude_self_usage"
# Deprecated configuration keys
CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED = "discovery_exclude_device_types"
CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED = "discovery_exclude_self_usage"
CONF_ENABLE_AUTODISCOVERY_DEPRECATED = "enable_autodiscovery"
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_SENSOR_CATEGORY = "energy_sensor_category"
CONF_ENERGY_SENSOR_FRIENDLY_NAMING = "energy_sensor_friendly_naming"
CONF_ENERGY_SENSOR_ID = "energy_sensor_id"
CONF_ENERGY_SENSOR_NAMING = "energy_sensor_naming"
CONF_ENERGY_SENSOR_PRECISION = "energy_sensor_precision"
CONF_ENERGY_SENSOR_UNIT_PREFIX = "energy_sensor_unit_prefix"
CONF_ENERGY_UPDATE_INTERVAL = "energy_update_interval"
CONF_ENERGY_FILTER_OUTLIER_ENABLED = "energy_filter_outlier_enabled"
CONF_ENERGY_FILTER_OUTLIER_MAX = "energy_filter_outlier_max_step"
CONF_EXCLUDE_ENTITIES = "exclude_entities"
CONF_FILTER = "filter"
CONF_FIXED = "fixed"
CONF_FLOOR = "floor"
CONF_FORCE_CALCULATE_GROUP_ENERGY = "force_calculate_group_energy"
CONF_FORCE_ENERGY_SENSOR_CREATION = "force_energy_sensor_creation"
CONF_GAMMA_CURVE = "gamma_curve"
CONF_GROUP = "group"
CONF_GROUP_ENERGY_ENTITIES = "group_energy_entities"
CONF_GROUP_ENERGY_START_AT_ZERO = "group_energy_start_at_zero"
CONF_GROUP_ENERGY_UPDATE_INTERVAL = "group_energy_update_interval"
CONF_GROUP_MEMBER_DEVICES = "group_member_devices"
CONF_GROUP_MEMBER_SENSORS = "group_member_sensors"
CONF_GROUP_POWER_ENTITIES = "group_power_entities"
CONF_GROUP_POWER_UPDATE_INTERVAL = "group_power_update_interval"
CONF_GROUP_TRACKED_AUTO = "group_tracked_auto"
CONF_GROUP_TRACKED_POWER_ENTITIES = "group_tracked_entities"
CONF_GROUP_TYPE = "group_type"
CONF_HIDE_MEMBERS = "hide_members"
CONF_IGNORE_UNAVAILABLE_STATE = "ignore_unavailable_state"
CONF_INCLUDE = "include"
CONF_INCLUDE_NON_POWERCALC_SENSORS = "include_non_powercalc_sensors"
CONF_LABEL = "label"
CONF_LINEAR = "linear"
CONF_LUT = "lut"
CONF_MAIN_POWER_SENSOR = "main_power_sensor"
CONF_MANUFACTURER = "manufacturer"
CONF_MAX_POWER = "max_power"
CONF_MIN_POWER = "min_power"
CONF_MODEL = "model"
CONF_MODE = "mode"
CONF_MULTI_SWITCH = "multi_switch"
CONF_MULTIPLY_FACTOR = "multiply_factor"
CONF_MULTIPLY_FACTOR_STANDBY = "multiply_factor_standby"
CONF_NEW_GROUP = "new_group"
CONF_NOT = "not"
CONF_ON_TIME = "on_time"
CONF_OR = "or"
CONF_PLAYBOOK = "playbook"
CONF_PLAYBOOKS = "playbooks"
CONF_POWER = "power"
CONF_POWER_FACTOR = "power_factor"
CONF_POWER_OFF = "power_off"
CONF_POWER_SENSOR_CATEGORY = "power_sensor_category"
CONF_POWER_SENSOR_FRIENDLY_NAMING = "power_sensor_friendly_naming"
CONF_POWER_SENSOR_ID = "power_sensor_id"
CONF_POWER_SENSOR_NAMING = "power_sensor_naming"
CONF_POWER_SENSOR_PRECISION = "power_sensor_precision"
CONF_POWER_TEMPLATE = "power_template"
CONF_POWER_UPDATE_INTERVAL = "power_update_interval"
CONF_REPEAT = "repeat"
CONF_SELF_USAGE_INCLUDED = "self_usage_included"
CONF_SENSOR_TYPE = "sensor_type"
CONF_SENSORS = "sensors"
CONF_SLEEP_POWER = "sleep_power"
CONF_STANDBY_POWER = "standby_power"
CONF_START_TIME = "start_time"
CONF_STATE = "state"
CONF_STATES_POWER = "states_power"
CONF_STATE_TRIGGER = "state_trigger"
CONF_STATES_TRIGGER = "states_trigger"
CONF_STRATEGIES = "strategies"
CONF_SUB_GROUPS = "sub_groups"
CONF_SUB_PROFILE = "sub_profile"
CONF_SUBTRACT_ENTITIES = "subtract_entities"
CONF_TEMPLATE = "template"
CONF_UNAVAILABLE_POWER = "unavailable_power"
CONF_UPDATE_FREQUENCY = "update_frequency"
CONF_UTILITY_METER_NET_CONSUMPTION = "utility_meter_net_consumption"
CONF_UTILITY_METER_OFFSET = "utility_meter_offset"
CONF_UTILITY_METER_TARIFFS = "utility_meter_tariffs"
CONF_UTILITY_METER_TYPES = "utility_meter_types"
CONF_VALUE = "value"
CONF_VALUE_TEMPLATE = "value_template"
CONF_VARIABLES = "variables"
CONF_VOLTAGE = "voltage"
CONF_WILDCARD = "wildcard"
CONF_WLED = "wled"
# Redefine constants from integration component.
# Has been refactored in HA 2022.4, we need to support older HA versions as well.
ENERGY_INTEGRATION_METHOD_LEFT = "left"
ENERGY_INTEGRATION_METHOD_RIGHT = "right"
ENERGY_INTEGRATION_METHOD_TRAPEZODIAL = "trapezoidal"
ENERGY_INTEGRATION_METHODS = [
ENERGY_INTEGRATION_METHOD_LEFT,
ENERGY_INTEGRATION_METHOD_RIGHT,
ENERGY_INTEGRATION_METHOD_TRAPEZODIAL,
]
class UnitPrefix(StrEnum):
"""Allowed unit prefixes."""
NONE = "none"
KILO = "k"
MEGA = "M"
GIGA = "G"
TERA = "T"
ENTITY_CATEGORIES = [
EntityCategory.CONFIG,
EntityCategory.DIAGNOSTIC,
None,
]
DEFAULT_GROUP_POWER_UPDATE_INTERVAL = 2
DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL = 60
DEFAULT_POWER_NAME_PATTERN = "{} power"
DEFAULT_POWER_SENSOR_PRECISION = 2
DEFAULT_ENERGY_UPDATE_INTERVAL = 600
DEFAULT_ENERGY_INTEGRATION_METHOD = ENERGY_INTEGRATION_METHOD_LEFT
DEFAULT_ENERGY_NAME_PATTERN = "{} energy"
DEFAULT_ENERGY_SENSOR_PRECISION = 4
DEFAULT_ENERGY_UNIT_PREFIX = UnitPrefix.KILO
DEFAULT_ENTITY_CATEGORY: str | None = None
DEFAULT_UTILITY_METER_TYPES = [DAILY, WEEKLY, MONTHLY]
DISCOVERY_SOURCE_ENTITY = "source_entity"
DISCOVERY_POWER_PROFILES = "power_profiles"
DISCOVERY_TYPE = "discovery_type"
LIBRARY_URL = "https://library.powercalc.nl"
API_URL = "https://api.powercalc.nl"
MANUFACTURER_WLED = "WLED"
ATTR_CALCULATION_MODE = "calculation_mode"
ATTR_ENERGY_SENSOR_ENTITY_ID = "energy_sensor_entity_id"
ATTR_ENTITIES = "entities"
ATTR_INTEGRATION = "integration"
ATTR_IS_GROUP = "is_group"
ATTR_SOURCE_ENTITY = "source_entity"
ATTR_SOURCE_DOMAIN = "source_domain"
SERVICE_ACTIVATE_PLAYBOOK = "activate_playbook"
SERVICE_CALIBRATE_UTILITY_METER = "calibrate_utility_meter"
SERVICE_CALIBRATE_ENERGY = "calibrate_energy"
SERVICE_CHANGE_GUI_CONFIGURATION = "change_gui_config"
SERVICE_GET_ACTIVE_PLAYBOOK = "get_active_playbook"
SERVICE_GET_GROUP_ENTITIES = "get_group_entities"
SERVICE_INCREASE_DAILY_ENERGY = "increase_daily_energy"
SERVICE_RESET_ENERGY = "reset_energy"
SERVICE_STOP_PLAYBOOK = "stop_playbook"
SERVICE_SWITCH_SUB_PROFILE = "switch_sub_profile"
SERVICE_UPDATE_LIBRARY = "update_library"
SERVICE_RELOAD = "reload"
SIGNAL_POWER_SENSOR_STATE_CHANGE = "powercalc_power_sensor_state_change"
OFF_STATES = {STATE_OFF, STATE_STANDBY, STATE_UNAVAILABLE}
OFF_STATES_BY_DOMAIN: dict[str, set[str]] = {
cover.DOMAIN: {STATE_CLOSED, STATE_OPEN},
device_tracker.DOMAIN: {STATE_NOT_HOME},
}
class CalculationStrategy(StrEnum):
"""Possible virtual power calculation strategies."""
COMPOSITE = "composite"
LUT = "lut"
LINEAR = "linear"
MULTI_SWITCH = "multi_switch"
FIXED = "fixed"
PLAYBOOK = "playbook"
WLED = "wled"
CALCULATION_STRATEGY_CONF_KEYS: list[str] = [strategy.value for strategy in CalculationStrategy]
class SensorType(StrEnum):
"""Possible modes for a number selector."""
DAILY_ENERGY = "daily_energy"
VIRTUAL_POWER = "virtual_power"
GROUP = "group"
REAL_POWER = "real_power"
class PowercalcDiscoveryType(StrEnum):
DOMAIN_GROUP = "domain_group"
STANDBY_GROUP = "standby_group"
LIBRARY = "library"
USER_YAML = "user_yaml"
class GroupType(StrEnum):
"""Possible group types."""
CUSTOM = "custom"
DOMAIN = "domain"
STANDBY = "standby"
SUBTRACT = "subtract"
TRACKED_UNTRACKED = "tracked_untracked"
class EntityType(StrEnum):
"""Possible powercalc entity types."""
POWER_SENSOR = "power_sensor"
ENERGY_SENSOR = "energy_sensor"
UTILITY_METER = "utility_meter"
TARIFF_SELECT = "tariff_select"
UNKNOWN = "unknown"
class PowerProfileSource(StrEnum):
"""Possible power profile sources."""
MANUAL = "manual"
LIBRARY_BUILTIN = "library_builtin"
LIBRARY_CUSTOM = "library_custom"
@@ -0,0 +1,115 @@
import logging
from awesomeversion import AwesomeVersion
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE,
__version__ as HA_VERSION, # noqa
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry
from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo
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_SENSOR_TYPE, SensorType
from custom_components.powercalc.sensors.abstract import BaseEntity
_LOGGER = logging.getLogger(__name__)
async def attach_entities_to_source_device(
config_entry: ConfigEntry | None,
entities_to_add: list[Entity],
hass: HomeAssistant,
source_entity: SourceEntity | 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)
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)):
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
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(
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:
"""
Get device info 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
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
if device is None:
return None
if not device.identifiers and not device.connections:
return None
return DeviceInfo(
identifiers=device.identifiers,
connections=device.connections,
)
@@ -0,0 +1,52 @@
import logging
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.reload import async_integration_yaml_config
from custom_components.powercalc import CONF_SENSOR_TYPE, DOMAIN, SensorType
from custom_components.powercalc.sensors.group.config_entry_utils import get_entries_excluding_global_config
from custom_components.powercalc.sensors.group.custom import resolve_entity_ids_recursively
_LOGGER = logging.getLogger(__name__)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
) -> dict:
"""Return diagnostics for a config entry."""
data: dict = {
"entry": entry.as_dict(),
"config_entry_count_per_type": await get_count_by_sensor_type(hass),
"yaml_config": await get_yaml_configuration(hass),
}
if entry.data.get(CONF_SENSOR_TYPE) == SensorType.GROUP:
data["power_entities"] = await resolve_entity_ids_recursively(hass, entry, SensorDeviceClass.POWER)
data["energy_entities"] = await resolve_entity_ids_recursively(hass, entry, SensorDeviceClass.ENERGY)
return data
async def get_count_by_sensor_type(hass: HomeAssistant) -> dict[SensorType, int]:
count_per_type = {}
entries = get_entries_excluding_global_config(hass)
for e in entries:
sensor_type = SensorType(e.data.get(CONF_SENSOR_TYPE, SensorType.VIRTUAL_POWER))
if sensor_type not in count_per_type:
count_per_type[sensor_type] = 0
count_per_type[sensor_type] += 1
return count_per_type
async def get_yaml_configuration(hass: HomeAssistant) -> dict:
"""Return the YAML configuration for powercalc integration."""
try:
yaml_config = await async_integration_yaml_config(hass, DOMAIN)
return yaml_config.get(DOMAIN, {}) # type: ignore
except Exception as err: # noqa: BLE001 # pragma: nocover
_LOGGER.error("Could not retrieve YAML config: %s", err)
return {}
+567
View File
@@ -0,0 +1,567 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import timedelta
from enum import StrEnum
import logging
import re
from typing import Any
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER, ConfigEntry
from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import discovery_flow
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.entity import EntityCategory
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .common import SourceEntity, create_source_entity
from .const import (
CONF_MANUFACTURER,
CONF_MODE,
CONF_MODEL,
CONF_SENSORS,
DATA_DISCOVERY_MANAGER,
DISCOVERY_POWER_PROFILES,
DISCOVERY_SOURCE_ENTITY,
DOMAIN,
DUMMY_ENTITY_ID,
MANUFACTURER_WLED,
CalculationStrategy,
)
from .group_include.filter import CategoryFilter, CompositeFilter, DomainFilter, FilterOperator, LambdaFilter, NotFilter, get_filtered_entity_list
from .helpers import get_or_create_unique_id
from .power_profile.factory import get_power_profile
from .power_profile.library import ModelInfo, ProfileLibrary
from .power_profile.power_profile import SUPPORTED_DOMAINS, DeviceType, DiscoveryBy, PowerProfile
_LOGGER = logging.getLogger(__name__)
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."""
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.ENTITY)
return profiles[0] if profiles else None
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
class DiscoveryStatus(StrEnum):
DISABLED = "disabled"
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
FINISHED = "finished"
class DiscoveryManager:
"""This class is responsible for scanning the HA instance for entities and their manufacturer / model info
It checks if any of these devices is supported in the powercalc library
When entities are found it will dispatch a discovery flow, so the user can add them to their HA instance.
"""
def __init__(
self,
hass: HomeAssistant,
ha_config: ConfigType,
exclude_device_types: list[DeviceType] | None = None,
exclude_self_usage_profiles: bool = False,
enabled: bool = True,
) -> None:
self.hass = hass
self.ha_config = ha_config
self.power_profiles: dict[str, PowerProfile | None] = {}
self.manually_configured_entities: list[str] | None = None
self.initialized_flows: set[str] = set()
self.library: ProfileLibrary | None = None
self._exclude_device_types = exclude_device_types or []
self._exclude_self_usage_profiles = exclude_self_usage_profiles or False
self._status = DiscoveryStatus.NOT_STARTED if enabled else DiscoveryStatus.DISABLED
async def setup(self) -> None:
"""Setup the discovery manager. Start initial discovery and setup interval based rediscovery."""
if self._status == DiscoveryStatus.DISABLED:
_LOGGER.debug("Discovery manager is disabled, skipping setup")
return
await self.start_discovery()
async def _rediscover(_: Any) -> None: # noqa: ANN401
"""Rediscover entities."""
await self.update_library_and_rediscover()
async_track_time_interval(
self.hass,
_rediscover,
timedelta(hours=2),
)
async def update_library_and_rediscover(self) -> None:
"""Update the library and rediscover entities."""
library = await self._get_library()
await library.initialize()
await self.start_discovery()
async def start_discovery(self) -> None:
"""Start the discovery procedure."""
if self._status == DiscoveryStatus.IN_PROGRESS:
_LOGGER.debug("Discovery already in progress, skipping new discovery run")
return
self._status = DiscoveryStatus.IN_PROGRESS
await self.initialize_existing_entries()
_LOGGER.debug("Start auto discovery")
_LOGGER.debug("Start entity discovery")
await self.perform_discovery(self.get_entities, self.create_entity_source, DiscoveryBy.ENTITY) # type: ignore[arg-type]
_LOGGER.debug("Start device discovery")
await self.perform_discovery(self.get_devices, self.create_device_source, DiscoveryBy.DEVICE) # type: ignore[arg-type]
_LOGGER.debug("Done auto discovery")
self._status = DiscoveryStatus.FINISHED
async def initialize_existing_entries(self) -> None:
"""Build a list of config entries which are already setup, to prevent duplicate discovery flows"""
for entry in self.hass.config_entries.async_entries(DOMAIN):
if not entry.unique_id:
continue # pragma: no cover
self.initialized_flows.add(entry.unique_id)
entity_id = entry.data.get(CONF_ENTITY_ID)
if not entity_id or entity_id == DUMMY_ENTITY_ID:
continue
entity = await create_source_entity(str(entity_id), self.hass)
if entity and entity.device_entry:
self.initialized_flows.add(f"pc_{entity.device_entry.id}")
self.initialized_flows.add(entity_id)
def remove_initialized_flow(self, entry: ConfigEntry) -> None:
"""Remove a flow from the initialized flows."""
if entry.unique_id:
self.initialized_flows.discard(entry.unique_id)
entity_id = entry.data.get(CONF_ENTITY_ID)
if entity_id:
self.initialized_flows.discard(entity_id)
async def perform_discovery(
self,
source_provider: Callable[[], Awaitable[list]],
source_creator: Callable[[er.RegistryEntry | dr.DeviceEntry], Awaitable[SourceEntity]],
discovery_type: DiscoveryBy,
) -> None:
"""Generalized discovery procedure for entities and devices."""
for source in await source_provider():
log_identifier = source.entity_id if discovery_type == DiscoveryBy.ENTITY else source.id
try:
model_info = await self.extract_model_info_from_device_info(source)
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)
continue
unique_id = self.create_unique_id(
source_entity,
discovery_type,
power_profiles[0] if power_profiles else None,
)
if self._is_already_discovered(source_entity, unique_id):
_LOGGER.debug(
"%s: Already setup with discovery, skipping new discovery (unique_id=%s)",
log_identifier,
unique_id,
)
continue
self._init_entity_discovery(model_info, unique_id, source_entity, log_identifier, power_profiles, {})
except Exception as err: # noqa: BLE001
_LOGGER.error(
"%s: Error during %s discovery: %s",
log_identifier,
discovery_type,
err,
)
async def discover_entity(
self,
source_entity: SourceEntity,
model_info: ModelInfo,
discovery_type: DiscoveryBy = DiscoveryBy.ENTITY,
) -> list[PowerProfile] | None:
if source_entity.entity_entry and self.is_wled_light(model_info, source_entity.entity_entry):
await self.init_wled_flow(model_info, source_entity)
return None
return await self.find_power_profiles(model_info, source_entity, discovery_type)
async def create_entity_source(self, entity_entry: er.RegistryEntry) -> SourceEntity:
"""Create SourceEntity for an entity."""
return await create_source_entity(entity_entry.entity_id, self.hass)
@staticmethod
async def create_device_source(device_entry: dr.DeviceEntry) -> SourceEntity:
"""Create SourceEntity for a device."""
return SourceEntity(
object_id=device_entry.name_by_user or device_entry.name or "",
name=device_entry.name,
entity_id=DUMMY_ENTITY_ID,
domain="sensor",
device_entry=device_entry,
)
@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.DEVICE:
device_id = source.object_id
if source.device_entry:
device_id = source.device_entry.id
return f"pc_{device_id}"
return get_or_create_unique_id({}, source, power_profile)
async def find_power_profiles(
self,
model_info: ModelInfo,
source_entity: SourceEntity,
discovery_type: DiscoveryBy,
) -> list[PowerProfile] | None:
"""Find power profiles for a given entity."""
library = await self._get_library()
models = await library.find_models(model_info)
if not models:
return None
power_profiles = []
for model_info in models:
profile = await get_power_profile(self.hass, {}, source_entity, model_info=model_info, process_variables=False)
if not profile or profile.discovery_by != discovery_type: # pragma: no cover
continue
if discovery_type == DiscoveryBy.ENTITY and not profile.is_entity_domain_supported(
source_entity.entity_entry, # type: ignore[arg-type]
):
continue
if profile.device_type in self._exclude_device_types:
continue
if self._exclude_self_usage_profiles and profile.only_self_usage:
continue
# Check if the entity's integration is compatible with the profile
if (
discovery_type == DiscoveryBy.ENTITY
and source_entity.entity_entry
and profile.compatible_integrations
and source_entity.entity_entry.platform not in profile.compatible_integrations
):
continue
power_profiles.append(profile)
return power_profiles
async def init_wled_flow(self, model_info: ModelInfo, source_entity: SourceEntity) -> None:
"""Initialize the discovery flow for a WLED light."""
if DeviceType.LIGHT in self._exclude_device_types:
return
unique_id = f"pc_{source_entity.device_entry.id}" if source_entity.device_entry else get_or_create_unique_id({}, source_entity, None)
if self._is_already_discovered(source_entity, unique_id):
_LOGGER.debug(
"%s: Already setup with discovery, skipping new discovery (unique_id=%s)",
source_entity.entity_id,
unique_id,
)
return
self._init_entity_discovery(
model_info,
unique_id,
source_entity,
source_entity.entity_id,
power_profiles=None,
extra_discovery_data={
CONF_MODE: CalculationStrategy.WLED,
},
)
@staticmethod
def is_wled_light(model_info: ModelInfo, entity_entry: er.RegistryEntry) -> bool:
"""Check if the entity is a WLED light."""
return (
model_info.manufacturer == MANUFACTURER_WLED
and entity_entry.domain == LIGHT_DOMAIN
and not re.search("master|segment", str(entity_entry.original_name), flags=re.IGNORECASE)
and not re.search("master|segment", str(entity_entry.entity_id), flags=re.IGNORECASE)
)
async def get_entities(self) -> list[er.RegistryEntry]:
"""Get all entities from entity registry which qualifies for discovery."""
def _check_already_configured(entity: er.RegistryEntry) -> bool:
has_user_config = self._is_user_configured(entity.entity_id)
if has_user_config:
_LOGGER.debug(
"%s: Entity is manually configured, skipping auto configuration",
entity.entity_id,
)
return has_user_config
entity_filter = CompositeFilter(
[
CategoryFilter(
[
EntityCategory.CONFIG,
EntityCategory.DIAGNOSTIC,
],
),
LambdaFilter(_check_already_configured),
LambdaFilter(lambda entity: entity.device_id is None),
LambdaFilter(lambda entity: entity.platform == "mqtt" and "segment" in entity.entity_id),
LambdaFilter(lambda entity: entity.platform in ["powercalc", "switch_as_x"]),
NotFilter(DomainFilter(SUPPORTED_DOMAINS)),
],
FilterOperator.OR,
)
return await get_filtered_entity_list(self.hass, NotFilter(entity_filter))
async def get_devices(self) -> list:
"""Fetch device entries."""
return list(dr.async_get(self.hass).devices.values())
def enable(self) -> None:
"""Enable the discovery."""
self._status = DiscoveryStatus.NOT_STARTED
async def disable(self) -> None:
"""Disable the discovery."""
self._status = DiscoveryStatus.DISABLED
self.initialized_flows = set()
flows = self.hass.config_entries.flow.async_progress_by_handler(DOMAIN)
for flow in flows:
if flow["context"]["source"] != SOURCE_INTEGRATION_DISCOVERY:
continue # pragma: no cover
self.hass.config_entries.flow.async_abort(flow["flow_id"])
return
async def extract_model_info_from_device_info(
self,
entry: er.RegistryEntry | dr.DeviceEntry | None,
) -> ModelInfo | None:
"""Try to auto discover manufacturer and model from the known device information."""
if not entry:
return None
log_identifier = entry.entity_id if isinstance(entry, er.RegistryEntry) else entry.id
if isinstance(entry, er.RegistryEntry):
model_info = await self.get_model_information_from_entity(entry)
else:
model_info = await self.get_model_information_from_device(entry)
if not model_info:
_LOGGER.debug(
"%s: Cannot autodiscover model, manufacturer or model unknown from device registry",
log_identifier,
)
return None
# Make sure we don't have a literal / in model_id,
# so we don't get issues with sublut directory matching down the road
# See github #658
if "/" in model_info.model:
model_info = ModelInfo(
model_info.manufacturer,
model_info.model.replace("/", "#slash#"),
model_info.model_id,
)
_LOGGER.debug(
"%s: Found model information on device (manufacturer=%s, model=%s, model_id=%s)",
log_identifier,
model_info.manufacturer,
model_info.model,
model_info.model_id,
)
return model_info
@staticmethod
async def get_model_information_from_device(device_entry: dr.DeviceEntry) -> ModelInfo | None:
"""See if we have enough information in device registry to automatically set up the power sensor."""
if device_entry.manufacturer is None or device_entry.model is None:
return None
# Strip whitespace: some integrations include trailing spaces
# see https://github.com/home-assistant/core/pull/166187
manufacturer = str(device_entry.manufacturer).strip()
model = str(device_entry.model).strip()
model_id = str(device_entry.model_id).strip() if hasattr(device_entry, "model_id") and device_entry.model_id else None
if len(manufacturer) == 0 or len(model) == 0:
return None
return ModelInfo(manufacturer, model, model_id)
async def get_model_information_from_entity(self, entity_entry: er.RegistryEntry) -> ModelInfo | None:
"""See if we have enough information in device registry to automatically setup the power sensor."""
if entity_entry.device_id is None:
return None
device_registry = dr.async_get(self.hass)
device_entry = device_registry.async_get(entity_entry.device_id)
if device_entry is None:
return None
return await self.get_model_information_from_device(device_entry)
@callback
def _init_entity_discovery(
self,
model_info: ModelInfo,
unique_id: str,
source_entity: SourceEntity,
log_identifier: str,
power_profiles: list[PowerProfile] | None,
extra_discovery_data: dict | None,
) -> None:
"""Dispatch the discovery flow for a given entity."""
discovery_data: dict[str, Any] = {
CONF_ENTITY_ID: source_entity.entity_id,
DISCOVERY_SOURCE_ENTITY: source_entity,
CONF_UNIQUE_ID: unique_id,
}
if power_profiles:
discovery_data[DISCOVERY_POWER_PROFILES] = power_profiles
if len(power_profiles) == 1:
power_profile = power_profiles[0]
discovery_data[CONF_MANUFACTURER] = power_profile.manufacturer
discovery_data[CONF_MODEL] = power_profile.model
if CONF_MANUFACTURER not in discovery_data:
discovery_data[CONF_MANUFACTURER] = model_info.manufacturer
if CONF_MODEL not in discovery_data:
discovery_data[CONF_MODEL] = model_info.model or model_info.model_id
if extra_discovery_data:
discovery_data.update(extra_discovery_data)
self.initialized_flows.add(unique_id)
if source_entity.entity_id != DUMMY_ENTITY_ID:
self.initialized_flows.add(source_entity.entity_id)
_LOGGER.debug("%s: Initiating discovery flow, unique_id=%s", log_identifier, unique_id)
discovery_flow.async_create_flow(
self.hass,
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data=discovery_data,
)
@property
def status(self) -> DiscoveryStatus:
"""Get the discovery status"""
return self._status
def _is_user_configured(self, entity_id: str) -> bool:
"""Check if user have setup powercalc sensors for a given entity_id.
Either with the YAML or GUI method.
"""
if not self.manually_configured_entities:
self.manually_configured_entities = self._load_manually_configured_entities()
return entity_id in self.manually_configured_entities
def _load_manually_configured_entities(self) -> list[str]:
"""Looks at the YAML and GUI config entries for all the configured entity_id's."""
entities = []
# Find entity ids in yaml config (Legacy)
if SENSOR_DOMAIN in self.ha_config: # pragma: no cover
sensor_config = self.ha_config.get(SENSOR_DOMAIN)
platform_entries = [item for item in sensor_config or {} if isinstance(item, dict) and item.get(CONF_PLATFORM) == DOMAIN]
for entry in platform_entries:
entities.extend(self._find_entity_ids_in_yaml_config(entry))
# Find entity ids in yaml config (New)
domain_config: ConfigType = self.ha_config.get(DOMAIN, {})
if CONF_SENSORS in domain_config:
sensors = domain_config[CONF_SENSORS]
for sensor_config in sensors:
entities.extend(self._find_entity_ids_in_yaml_config(sensor_config))
# Add entities from existing config entries
entities.extend(
[str(entry.data.get(CONF_ENTITY_ID)) for entry in self.hass.config_entries.async_entries(DOMAIN) if entry.source == SOURCE_USER],
)
return entities
def _find_entity_ids_in_yaml_config(self, search_dict: dict) -> list[str]:
"""Takes a dict with nested lists and dicts,
and searches all dicts for a key of the field
provided.
"""
found_entity_ids: list[str] = []
self._extract_entity_ids(search_dict, found_entity_ids)
return found_entity_ids
def _extract_entity_ids(self, search_dict: dict, found_entity_ids: list[str]) -> None:
"""Helper function to recursively extract entity IDs."""
for key, value in search_dict.items():
if key == CONF_ENTITY_ID:
found_entity_ids.append(value)
elif isinstance(value, dict):
self._extract_entity_ids(value, found_entity_ids)
elif isinstance(value, list):
self._process_list_items(value, found_entity_ids)
def _process_list_items(self, items: list, found_entity_ids: list[str]) -> None:
"""Helper function to process list items."""
for item in items:
if isinstance(item, dict):
self._extract_entity_ids(item, found_entity_ids)
def _is_already_discovered(self, source_entity: SourceEntity, unique_id: str) -> bool:
"""Prevent duplicate discovery flows."""
unique_ids_to_check = [unique_id, source_entity.entity_id, source_entity.unique_id]
if unique_id.startswith("pc_"):
unique_ids_to_check.append(unique_id[3:])
unique_ids_to_check.extend([f"pc_{uid}" for uid in unique_ids_to_check])
return any(unique_id in self.initialized_flows for unique_id in unique_ids_to_check)
async def _get_library(self) -> ProfileLibrary:
"""Get the powercalc library instance."""
if not self.library:
self.library = await ProfileLibrary.factory(self.hass)
return self.library
+54
View File
@@ -0,0 +1,54 @@
"""Errors for the power component."""
from __future__ import annotations
from homeassistant.exceptions import HomeAssistantError
class PowercalcSetupError(HomeAssistantError):
"""Raised when an error occurred during powercalc sensor setup."""
class SensorConfigurationError(PowercalcSetupError):
"""Raised when sensor configuration is invalid."""
class SensorAlreadyConfiguredError(SensorConfigurationError):
"""Raised when power sensors has already been configured before for the entity."""
def __init__(
self,
source_entity_id: str,
existing_entities: list,
) -> None:
self.existing_entities = existing_entities
super().__init__(
f"{source_entity_id}: This entity has already configured a power sensor. "
"When you want to configure it twice make sure to give it a unique_id",
)
def get_existing_entities(self) -> list:
return self.existing_entities
class StrategyConfigurationError(PowercalcSetupError):
"""Raised when strategy is not setup correctly."""
def __init__(self, message: str, config_flow_trans_key: str | None = None) -> None:
super().__init__(message)
self._config_flow_trans_key = config_flow_trans_key
def get_config_flow_translate_key(self) -> str | None:
return self._config_flow_trans_key
class ModelNotSupportedError(StrategyConfigurationError):
"""Raised when model is not supported."""
class UnsupportedStrategyError(PowercalcSetupError):
"""Mode not supported."""
class LutFileNotFoundError(PowercalcSetupError):
"""Raised when LUT CSV file does not exist."""
@@ -0,0 +1,66 @@
from __future__ import annotations
from collections import deque
import statistics
class OutlierFilter:
"""Simple rolling-window outlier filter using median + MAD.
- Warm-up: accepts the first `min_samples` values unconditionally.
- After that: rejects values whose modified Z-score > `max_z_score`.
"""
def __init__(
self,
window_size: int = 30,
min_samples: int = 10,
max_z_score: float = 5.0,
max_expected_step: int = 1000,
) -> None:
self._window_size = window_size
self._min_samples = min_samples
self._max_z_score = max_z_score
self._values: deque[float] = deque(maxlen=window_size)
self._max_expected_step = max_expected_step
@property
def values(self) -> list[float]:
return list(self._values)
def _is_outlier(self, value: float) -> bool:
"""Return True if value is considered an outlier."""
if len(self._values) < self._min_samples:
return False
median = statistics.median(self._values)
# 1) Always allow downward transitions (light turning OFF)
if value <= median:
return False
# 2) Allow reasonable upward transitions (light turning ON)
# e.g. below 200 W difference - always accept
if value - median < self._max_expected_step:
return False
# 3) For larger jumps, use proper outlier detection (MAD)
abs_devs = [abs(x - median) for x in self._values]
mad = statistics.median(abs_devs) or 0
if mad == 0:
return False # pragma: no cover
z = 0.6745 * (value - median) / mad
return abs(z) > self._max_z_score
def accept(self, value: float) -> bool:
"""Return True if value should be accepted (not an outlier).
Also updates the internal window if the value is accepted.
"""
if self._is_outlier(value):
return False
self._values.append(value)
return True
@@ -0,0 +1,96 @@
from collections.abc import Callable, Coroutine
import copy
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
import voluptuous as vol
class Step(StrEnum):
ADVANCED_OPTIONS = "advanced_options"
ASSIGN_GROUPS = "assign_groups"
AVAILABILITY_ENTITY = "availability_entity"
BASIC_OPTIONS = "basic_options"
GROUP_CUSTOM = "group_custom"
GROUP_DOMAIN = "group_domain"
GROUP_SUBTRACT = "group_subtract"
GROUP_TRACKED_UNTRACKED = "group_tracked_untracked"
GROUP_TRACKED_UNTRACKED_AUTO = "group_tracked_untracked_auto"
GROUP_TRACKED_UNTRACKED_MANUAL = "group_tracked_untracked_manual"
LIBRARY = "library"
POST_LIBRARY = "post_library"
LIBRARY_CUSTOM_FIELDS = "library_custom_fields"
LIBRARY_MULTI_PROFILE = "library_multi_profile"
LIBRARY_OPTIONS = "library_options"
VIRTUAL_POWER = "virtual_power"
FIXED = "fixed"
LINEAR = "linear"
MULTI_SWITCH = "multi_switch"
PLAYBOOK = "playbook"
WLED = "wled"
POWER_ADVANCED = "power_advanced"
DAILY_ENERGY = "daily_energy"
REAL_POWER = "real_power"
MANUFACTURER = "manufacturer"
MENU_LIBRARY = "menu_library"
MENU_GROUP = "menu_group"
MODEL = "model"
SUB_PROFILE = "sub_profile"
SUB_PROFILE_PER_DEVICE = "sub_profile_per_device"
USER = "user"
SMART_SWITCH = "smart_switch"
INIT = "init"
ENERGY_OPTIONS = "energy_options"
UTILITY_METER_OPTIONS = "utility_meter_options"
GLOBAL_CONFIGURATION = "global_configuration"
GLOBAL_CONFIGURATION_DISCOVERY = "global_configuration_discovery"
GLOBAL_CONFIGURATION_ENERGY = "global_configuration_energy"
GLOBAL_CONFIGURATION_THROTTLING = "global_configuration_throttling"
GLOBAL_CONFIGURATION_UTILITY_METER = "global_configuration_utility_meter"
class FlowType(StrEnum):
VIRTUAL_POWER = "virtual_power"
DAILY_ENERGY = "daily_energy"
REAL_POWER = "real_power"
LIBRARY = "library"
GROUP = "group"
GLOBAL_CONFIGURATION = "global_configuration"
@dataclass(slots=True)
class PowercalcFormStep:
schema: vol.Schema | Callable[[], Coroutine[Any, Any, vol.Schema | None]]
step: Step
validate_user_input: (
Callable[
[dict[str, Any]],
Coroutine[Any, Any, dict[str, Any]],
]
| None
) = None
next_step: Step | Callable[[dict[str, Any]], Coroutine[Any, Any, Step | None]] | None = None
continue_utility_meter_options_step: bool = False
continue_advanced_step: bool = False
form_kwarg: dict[str, Any] | None = None
form_data: dict[str, Any] | None = None
def fill_schema_defaults(
data_schema: vol.Schema,
options: dict[str, Any],
) -> vol.Schema:
"""Make a copy of the schema with suggested values set to saved options."""
schema = {}
for key, val in data_schema.schema.items():
new_key = key
if 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
elif "suggested_value" not in (new_key.description or {}):
new_key = copy.copy(key)
new_key.description = {"suggested_value": options.get(key)} # type: ignore
schema[new_key] = val
return vol.Schema(schema)
@@ -0,0 +1,29 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.selector import selector
import voluptuous as vol
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.power_profile.power_profile import PowerProfile
def build_dynamic_field_schema(hass: HomeAssistant, profile: PowerProfile, source_entity: SourceEntity | None) -> vol.Schema:
schema = {}
for field in profile.custom_fields:
field_description = field.description
if not field_description:
field_description = field.label
if field.default is not None:
key = vol.Required(field.key, description=field_description, default=field.default)
else:
key = vol.Required(field.key, description=field_description)
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)
]
schema[key] = selector(field.selector)
return vol.Schema(schema)
@@ -0,0 +1,113 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfPower, UnitOfTime
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
import voluptuous as vol
from custom_components.powercalc import CONF_CREATE_UTILITY_METERS
from custom_components.powercalc.const import (
CONF_DAILY_FIXED_ENERGY,
CONF_GROUP,
CONF_ON_TIME,
CONF_UPDATE_FREQUENCY,
CONF_VALUE,
CONF_VALUE_TEMPLATE,
SensorType,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, fill_schema_defaults
from custom_components.powercalc.flow_helper.schema import SCHEMA_UTILITY_METER_TOGGLE
from custom_components.powercalc.sensors.daily_energy import DEFAULT_DAILY_UPDATE_FREQUENCY
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcConfigFlow, PowercalcOptionsFlow
SCHEMA_DAILY_ENERGY_OPTIONS = vol.Schema(
{
vol.Optional(CONF_VALUE): vol.Coerce(float),
vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(),
vol.Optional(
CONF_UNIT_OF_MEASUREMENT,
default=UnitOfEnergy.KILO_WATT_HOUR,
): vol.In(
[UnitOfEnergy.KILO_WATT_HOUR, UnitOfPower.WATT],
),
vol.Optional(CONF_ON_TIME): selector.DurationSelector(
selector.DurationSelectorConfig(enable_day=False),
),
vol.Optional(
CONF_UPDATE_FREQUENCY,
default=DEFAULT_DAILY_UPDATE_FREQUENCY,
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=10,
unit_of_measurement=UnitOfTime.SECONDS,
mode=selector.NumberSelectorMode.BOX,
),
),
},
)
SCHEMA_DAILY_ENERGY = vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
).extend(SCHEMA_DAILY_ENERGY_OPTIONS.schema)
def build_daily_energy_config(user_input: dict[str, Any], schema: vol.Schema) -> dict[str, Any]:
"""Build the config under daily_energy: key."""
config: dict[str, Any] = {
CONF_DAILY_FIXED_ENERGY: {},
}
for key, val in user_input.items():
if key in schema.schema and val is not None:
if key in {CONF_CREATE_UTILITY_METERS, CONF_GROUP, CONF_NAME, CONF_UNIQUE_ID}:
config[str(key)] = val
continue
config[CONF_DAILY_FIXED_ENERGY][str(key)] = val
return config
class DailyEnergyConfigFlow:
def __init__(self, flow: PowercalcConfigFlow) -> None:
self.flow: PowercalcConfigFlow = flow
async def async_step_daily_energy(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle the flow for daily energy sensor."""
self.flow.selected_sensor_type = SensorType.DAILY_ENERGY
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
if CONF_VALUE not in user_input and CONF_VALUE_TEMPLATE not in user_input:
raise SchemaFlowError("daily_energy_mandatory")
return build_daily_energy_config(user_input, SCHEMA_DAILY_ENERGY)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.DAILY_ENERGY,
schema=SCHEMA_DAILY_ENERGY,
validate_user_input=_validate,
next_step=Step.ASSIGN_GROUPS,
),
user_input,
)
class DailyEnergyOptionsFlow:
def __init__(self, flow: PowercalcOptionsFlow) -> None:
self.flow: PowercalcOptionsFlow = flow
async def async_step_daily_energy(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the daily energy options flow."""
schema = fill_schema_defaults(
SCHEMA_DAILY_ENERGY_OPTIONS,
self.flow.sensor_config[CONF_DAILY_FIXED_ENERGY],
)
return await self.flow.async_handle_options_step(user_input, schema, Step.DAILY_ENERGY)
@@ -0,0 +1,287 @@
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.const import CONF_ENABLED, CONF_SENSORS, UnitOfTime
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from custom_components.powercalc import DeviceType
from custom_components.powercalc.const import (
CONF_CREATE_ENERGY_SENSORS,
CONF_CREATE_STANDBY_GROUP,
CONF_CREATE_UTILITY_METERS,
CONF_DISABLE_EXTENDED_ATTRIBUTES,
CONF_DISABLE_LIBRARY_DOWNLOAD,
CONF_DISCOVERY,
CONF_ENABLE_ANALYTICS,
CONF_ENERGY_SENSOR_CATEGORY,
CONF_ENERGY_SENSOR_FRIENDLY_NAMING,
CONF_ENERGY_SENSOR_NAMING,
CONF_ENERGY_SENSOR_PRECISION,
CONF_ENERGY_UPDATE_INTERVAL,
CONF_EXCLUDE_DEVICE_TYPES,
CONF_EXCLUDE_SELF_USAGE,
CONF_GROUP_ENERGY_UPDATE_INTERVAL,
CONF_GROUP_POWER_UPDATE_INTERVAL,
CONF_IGNORE_UNAVAILABLE_STATE,
CONF_INCLUDE_NON_POWERCALC_SENSORS,
CONF_POWER_SENSOR_CATEGORY,
CONF_POWER_SENSOR_FRIENDLY_NAMING,
CONF_POWER_SENSOR_NAMING,
CONF_POWER_SENSOR_PRECISION,
CONF_POWER_UPDATE_INTERVAL,
CONF_UTILITY_METER_OFFSET,
DEFAULT_ENERGY_UPDATE_INTERVAL,
DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL,
DEFAULT_GROUP_POWER_UPDATE_INTERVAL,
DOMAIN,
DOMAIN_CONFIG,
ENTITY_CATEGORIES,
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step
from custom_components.powercalc.flow_helper.schema import SCHEMA_ENERGY_OPTIONS, SCHEMA_UTILITY_METER_OPTIONS, SCHEMA_UTILITY_METER_TOGGLE
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
SCHEMA_GLOBAL_CONFIGURATION = vol.Schema(
{
vol.Optional(CONF_POWER_SENSOR_NAMING): selector.TextSelector(),
vol.Optional(CONF_POWER_SENSOR_FRIENDLY_NAMING): selector.TextSelector(),
vol.Optional(CONF_POWER_SENSOR_CATEGORY): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(filter(lambda item: item is not None, ENTITY_CATEGORIES)), # type: ignore
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
vol.Optional(CONF_POWER_SENSOR_PRECISION): selector.NumberSelector(
selector.NumberSelectorConfig(min=0, max=6, mode=selector.NumberSelectorMode.BOX, step=1),
),
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,
},
)
SCHEMA_GLOBAL_CONFIGURATION_DISCOVERY = vol.Schema(
{
vol.Optional(CONF_ENABLED, default=True): selector.BooleanSelector(),
vol.Optional(CONF_EXCLUDE_DEVICE_TYPES): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[cls.value for cls in DeviceType],
mode=selector.SelectSelectorMode.DROPDOWN,
multiple=True,
),
),
vol.Optional(CONF_EXCLUDE_SELF_USAGE, default=False): selector.BooleanSelector(),
},
)
SCHEMA_GLOBAL_CONFIGURATION_THROTTLING = vol.Schema(
{
vol.Optional(CONF_POWER_UPDATE_INTERVAL, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
vol.Optional(CONF_ENERGY_UPDATE_INTERVAL, default=DEFAULT_ENERGY_UPDATE_INTERVAL): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
vol.Optional(CONF_GROUP_POWER_UPDATE_INTERVAL, default=DEFAULT_GROUP_POWER_UPDATE_INTERVAL): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
vol.Optional(CONF_GROUP_ENERGY_UPDATE_INTERVAL, default=DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL): selector.NumberSelector(
selector.NumberSelectorConfig(unit_of_measurement=UnitOfTime.SECONDS, mode=selector.NumberSelectorMode.BOX),
),
},
)
SCHEMA_GLOBAL_CONFIGURATION_ENERGY_SENSOR = vol.Schema(
{
vol.Optional(CONF_ENERGY_SENSOR_NAMING): selector.TextSelector(),
vol.Optional(CONF_ENERGY_SENSOR_FRIENDLY_NAMING): selector.TextSelector(),
vol.Optional(CONF_ENERGY_SENSOR_CATEGORY): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(filter(lambda item: item is not None, ENTITY_CATEGORIES)), # type: ignore
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
**SCHEMA_ENERGY_OPTIONS.schema,
vol.Optional(CONF_ENERGY_SENSOR_PRECISION): selector.NumberSelector(
selector.NumberSelectorConfig(min=0, max=6, mode=selector.NumberSelectorMode.BOX, step=1),
),
},
)
def get_global_powercalc_config(flow: PowercalcCommonFlow) -> ConfigType:
"""Get the global powercalc config."""
if flow.global_config:
return flow.global_config
powercalc = flow.hass.data.get(DOMAIN) or {}
global_config = dict.copy(powercalc.get(DOMAIN_CONFIG) or {})
utility_meter_offset = global_config.get(CONF_UTILITY_METER_OFFSET)
if isinstance(utility_meter_offset, timedelta):
global_config[CONF_UTILITY_METER_OFFSET] = utility_meter_offset.days
if CONF_SENSORS in global_config:
global_config.pop(CONF_SENSORS)
flow.global_config = global_config
return global_config
class GlobalConfigurationFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
async def async_step_global_configuration_discovery(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the discovery configuration step."""
if user_input is not None:
self.flow.global_config.update({CONF_DISCOVERY: user_input})
if self.flow.is_options_flow:
return self.flow.persist_config_entry()
global_config = get_global_powercalc_config(self.flow)
discovery_options: dict[str, Any] = global_config.get(CONF_DISCOVERY, {})
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_DISCOVERY,
schema=SCHEMA_GLOBAL_CONFIGURATION_DISCOVERY,
next_step=Step.GLOBAL_CONFIGURATION_THROTTLING,
form_data=discovery_options,
form_kwarg={"description_placeholders": {"docs_uri": "https://docs.powercalc.nl/library/discovery/"}},
),
user_input,
)
async def async_step_global_configuration_throttling(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the throttling related options."""
if user_input is not None:
self.flow.global_config.update(user_input)
if self.flow.is_options_flow:
return self.flow.persist_config_entry()
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_THROTTLING,
schema=SCHEMA_GLOBAL_CONFIGURATION_THROTTLING,
next_step=Step.GLOBAL_CONFIGURATION_ENERGY,
form_kwarg={
"description_placeholders": {
"docs_uri": "https://docs.powercalc.nl/configuration/update-frequency/",
},
},
),
user_input,
)
async def async_step_global_configuration_energy(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the global configuration step."""
if user_input is not None:
self.flow.global_config.update(user_input)
if self.flow.is_options_flow:
return self.flow.persist_config_entry()
if not bool(self.flow.global_config.get(CONF_CREATE_ENERGY_SENSORS)) or user_input is not None:
return await self.async_step_global_configuration_utility_meter()
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_ENERGY,
schema=SCHEMA_GLOBAL_CONFIGURATION_ENERGY_SENSOR,
form_kwarg={"description_placeholders": {"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/"}},
),
)
async def async_step_global_configuration_utility_meter(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the global configuration step."""
if user_input is not None:
self.flow.global_config.update(user_input)
if self.flow.is_options_flow:
return self.flow.persist_config_entry()
if not bool(self.flow.global_config.get(CONF_CREATE_UTILITY_METERS)) or user_input is not None:
return self.flow.async_create_entry( # type: ignore
title="Global Configuration",
data=self.flow.global_config,
)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION_UTILITY_METER,
schema=SCHEMA_UTILITY_METER_OPTIONS,
form_kwarg={"description_placeholders": {"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/"}},
),
)
class GlobalConfigurationConfigFlow(GlobalConfigurationFlow):
def __init__(self, flow: PowercalcConfigFlow) -> None:
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the global configuration step."""
get_global_powercalc_config(self.flow)
await self.flow.async_set_unique_id(ENTRY_GLOBAL_CONFIG_UNIQUE_ID)
self.flow.abort_if_unique_id_configured()
if user_input is not None:
self.flow.global_config.update(user_input)
return await self.async_step_global_configuration_discovery()
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION,
schema=SCHEMA_GLOBAL_CONFIGURATION,
form_kwarg={
"description_placeholders": {
"docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/",
},
},
),
)
class GlobalConfigurationOptionsFlow(GlobalConfigurationFlow):
def __init__(self, flow: PowercalcOptionsFlow) -> None:
super().__init__(flow)
self.flow = flow
def build_global_config_menu(self) -> dict[Step, str]:
"""Build menu for global configuration"""
menu = {
Step.GLOBAL_CONFIGURATION: "Basic options",
Step.GLOBAL_CONFIGURATION_DISCOVERY: "Discovery options",
Step.GLOBAL_CONFIGURATION_THROTTLING: "Throttling options",
}
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_UTILITY_METERS):
menu[Step.GLOBAL_CONFIGURATION_UTILITY_METER] = "Utility meter options"
return menu
async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the global configuration step."""
if user_input is not None:
self.flow.global_config.update(user_input)
return self.flow.persist_config_entry()
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GLOBAL_CONFIGURATION,
schema=SCHEMA_GLOBAL_CONFIGURATION,
),
)
@@ -0,0 +1,504 @@
"""Group-related logic for the config flow."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.const import (
CONF_DEVICE,
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_NAME,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
from homeassistant.helpers.selector import TextSelector
import voluptuous as vol
from custom_components.powercalc.const import (
CONF_AREA,
CONF_EXCLUDE_ENTITIES,
CONF_FLOOR,
CONF_FORCE_CALCULATE_GROUP_ENERGY,
CONF_GROUP,
CONF_GROUP_ENERGY_ENTITIES,
CONF_GROUP_ENERGY_START_AT_ZERO,
CONF_GROUP_MEMBER_DEVICES,
CONF_GROUP_MEMBER_SENSORS,
CONF_GROUP_POWER_ENTITIES,
CONF_GROUP_TRACKED_AUTO,
CONF_GROUP_TRACKED_POWER_ENTITIES,
CONF_GROUP_TYPE,
CONF_HIDE_MEMBERS,
CONF_INCLUDE_NON_POWERCALC_SENSORS,
CONF_MAIN_POWER_SENSOR,
CONF_NEW_GROUP,
CONF_SENSOR_TYPE,
CONF_SUB_GROUPS,
CONF_SUBTRACT_ENTITIES,
DOMAIN,
GroupType,
SensorType,
)
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, fill_schema_defaults
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
from custom_components.powercalc.sensors.group.tracked_untracked import find_auto_tracked_power_entities
from custom_components.powercalc.sensors.power import PowerSensor
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
# Constants
UNIQUE_ID_TRACKED_UNTRACKED = "pc_tracked_untracked"
# Schemas
SCHEMA_GROUP = vol.Schema(
{
vol.Required(CONF_NAME): str,
vol.Optional(CONF_DEVICE): selector.DeviceSelector(),
},
)
SCHEMA_GROUP_DOMAIN_OPTIONS = vol.Schema(
{
vol.Required(CONF_DOMAIN): selector.SelectSelector(
selector.SelectSelectorConfig(
options=["all"] + [cls.value for cls in Platform],
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
vol.Optional(CONF_EXCLUDE_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=[SensorDeviceClass.ENERGY, SensorDeviceClass.POWER],
multiple=True,
),
),
},
)
SCHEMA_GROUP_DOMAIN = vol.Schema(
{
vol.Required(CONF_NAME): str,
**SCHEMA_GROUP_DOMAIN_OPTIONS.schema,
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_GROUP_SUBTRACT_OPTIONS = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
multiple=False,
),
),
vol.Optional(CONF_SUBTRACT_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
multiple=True,
),
),
},
)
SCHEMA_GROUP_SUBTRACT = vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
**SCHEMA_GROUP_SUBTRACT_OPTIONS.schema,
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_GROUP_TRACKED_UNTRACKED = vol.Schema(
{
vol.Optional(CONF_MAIN_POWER_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
),
),
vol.Required(CONF_GROUP_TRACKED_AUTO): selector.BooleanSelector(),
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_GROUP_TRACKED_UNTRACKED_MANUAL = vol.Schema(
{
vol.Required(CONF_GROUP_TRACKED_POWER_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
multiple=True,
),
),
},
)
MENU_GROUP = [
Step.GROUP_CUSTOM,
Step.GROUP_DOMAIN,
Step.GROUP_SUBTRACT,
Step.GROUP_TRACKED_UNTRACKED,
]
# Mappings
GROUP_SCHEMAS: dict[GroupType, vol.Schema] = {
GroupType.CUSTOM: SCHEMA_GROUP,
GroupType.DOMAIN: SCHEMA_GROUP_DOMAIN,
GroupType.SUBTRACT: SCHEMA_GROUP_SUBTRACT,
GroupType.TRACKED_UNTRACKED: SCHEMA_GROUP_TRACKED_UNTRACKED,
}
GROUP_STEP_MAPPING: dict[GroupType, Step] = {
GroupType.CUSTOM: Step.GROUP_CUSTOM,
GroupType.DOMAIN: Step.GROUP_DOMAIN,
GroupType.STANDBY: Step.GROUP_DOMAIN,
GroupType.SUBTRACT: Step.GROUP_SUBTRACT,
GroupType.TRACKED_UNTRACKED: Step.GROUP_TRACKED_UNTRACKED,
}
def validate_group_input(user_input: dict[str, Any] | None = None) -> None:
"""Validate the group form."""
required_keys = {
CONF_SUB_GROUPS,
CONF_GROUP_POWER_ENTITIES,
CONF_GROUP_ENERGY_ENTITIES,
CONF_GROUP_MEMBER_SENSORS,
CONF_GROUP_MEMBER_DEVICES,
CONF_AREA,
CONF_FLOOR,
}
if not any(key in (user_input or {}) for key in required_keys):
raise SchemaFlowError("group_mandatory")
def create_schema_group_custom(
hass: HomeAssistant,
config_entry: ConfigEntry | None = None,
is_option_flow: bool = False,
) -> vol.Schema:
"""Create config schema for groups."""
member_sensors = [
selector.SelectOptionDict(value=config_entry.entry_id, label=config_entry.title)
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.data.get(CONF_SENSOR_TYPE) in [SensorType.VIRTUAL_POWER, SensorType.REAL_POWER]
and config_entry.unique_id is not None
and config_entry.title is not None
]
member_sensor_selector = selector.SelectSelector(
selector.SelectSelectorConfig(
options=member_sensors,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
),
)
schema = vol.Schema(
{
vol.Optional(CONF_GROUP_MEMBER_SENSORS): member_sensor_selector,
vol.Optional(CONF_GROUP_MEMBER_DEVICES): selector.DeviceSelector(
selector.DeviceSelectorConfig(
multiple=True,
entity=selector.EntityFilterSelectorConfig(
device_class=[SensorDeviceClass.POWER, SensorDeviceClass.ENERGY],
),
),
),
vol.Optional(CONF_GROUP_POWER_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.POWER,
multiple=True,
),
),
vol.Optional(CONF_GROUP_ENERGY_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=Platform.SENSOR,
device_class=SensorDeviceClass.ENERGY,
multiple=True,
),
),
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(),
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(),
vol.Optional(CONF_FORCE_CALCULATE_GROUP_ENERGY, default=False): selector.BooleanSelector(),
},
)
if not is_option_flow:
schema = schema.extend(
{
vol.Optional(CONF_GROUP_ENERGY_START_AT_ZERO, default=True): selector.BooleanSelector(),
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
return schema
def create_group_selector(
hass: HomeAssistant,
current_entry: ConfigEntry | None = None,
group_entries: list[ConfigEntry] | None = None,
) -> selector.SelectSelector:
"""Create the group selector."""
options = [
selector.SelectOptionDict(
value=config_entry.entry_id,
label=config_entry.title,
)
for config_entry in (group_entries or get_group_entries(hass, GroupType.CUSTOM))
if current_entry is None or config_entry.entry_id != current_entry.entry_id
]
return selector.SelectSelector(
selector.SelectSelectorConfig(
options=options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=True,
),
)
async def create_schema_tracked_untracked_auto(hass: HomeAssistant) -> vol.Schema:
"""Handle the flow for tracked/untracked group sensor."""
tracked_entities = await find_auto_tracked_power_entities(hass)
return vol.Schema(
{
vol.Optional(CONF_EXCLUDE_ENTITIES): selector.EntitySelector(
selector.EntitySelectorConfig(
multiple=True,
include_entities=list(tracked_entities),
),
),
},
)
async def create_schema_group_tracked_untracked_manual(
hass: HomeAssistant,
user_input: dict[str, Any] | None = None,
schema: vol.Schema | None = None,
) -> vol.Schema:
"""Handle the flow for tracked/untracked group sensor."""
if not schema:
schema = SCHEMA_GROUP_TRACKED_UNTRACKED_MANUAL
if not user_input:
result = await find_entities(hass)
tracked_entities = [entity.entity_id for entity in result.resolved if isinstance(entity, PowerSensor)]
schema = fill_schema_defaults(schema, {CONF_GROUP_TRACKED_POWER_ENTITIES: tracked_entities})
return schema
class GroupFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
async def async_step_assign_groups(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for assigning groups."""
group_entries = get_group_entries(self.flow.hass, GroupType.CUSTOM)
if not group_entries:
return await self.flow.handle_final_steps()
schema = vol.Schema(
{
vol.Optional(CONF_GROUP): create_group_selector(self.flow.hass, group_entries=group_entries),
vol.Optional(CONF_NEW_GROUP): TextSelector(),
},
)
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
groups = user_input.get(CONF_GROUP) or []
new_group = user_input.get(CONF_NEW_GROUP)
if new_group:
groups.append(new_group)
return {CONF_GROUP: groups}
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.ASSIGN_GROUPS,
schema=schema,
continue_advanced_step=True,
continue_utility_meter_options_step=True,
validate_user_input=_validate,
),
user_input,
)
class GroupConfigFlow(GroupFlow):
"""
Encapsulates all group-related steps for config & options flows.
Composition-based: call from ConfigFlow/OptionsFlow and delegate here.
Expects the parent 'flow' to expose:
- hass
- sensor_config: dict
- name: str | None
- selected_sensor_type: str | None
- async_set_unique_id(), _abort_if_unique_id_configured()
- handle_form_step(PowercalcFormStep, user_input) -> FlowResult
- async_show_menu(...), fill_schema_defaults(...),
- create_group_selector(...), create_schema_group_custom(...)
We deliberately keep this controller dumb: it only handles group UX.
"""
def __init__(self, flow: PowercalcConfigFlow) -> None:
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
async def async_step_menu_group(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
menu = [Step.GROUP_CUSTOM, Step.GROUP_DOMAIN, Step.GROUP_SUBTRACT, Step.GROUP_TRACKED_UNTRACKED]
# Hide tracked/untracked if already present
entry = self.flow.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN,
UNIQUE_ID_TRACKED_UNTRACKED,
)
if entry:
menu.remove(Step.GROUP_TRACKED_UNTRACKED)
return self.flow.async_show_menu(step_id=Step.MENU_GROUP, menu_options=menu)
async def handle_group_step(
self,
group_type: GroupType,
user_input: dict[str, Any] | None = None,
schema: vol.Schema | None = None,
next_step: Callable[[dict[str, Any]], Coroutine[Any, Any, Step | None]] | None = None,
) -> FlowResult:
async def _validate(ui: dict[str, Any]) -> dict[str, Any]:
if group_type == GroupType.CUSTOM:
validate_group_input(ui)
self.flow.name = ui.get(CONF_NAME)
self.flow.sensor_config.update(ui)
self.flow.sensor_config.update({CONF_GROUP_TYPE: group_type})
return ui
self.flow.selected_sensor_type = SensorType.GROUP
step = GROUP_STEP_MAPPING[group_type]
return await self.flow.handle_form_step(
PowercalcFormStep(
step=step,
schema=schema or GROUP_SCHEMAS[group_type],
validate_user_input=_validate,
continue_utility_meter_options_step=True,
next_step=next_step,
),
user_input,
)
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> FlowResult:
schema = SCHEMA_GROUP.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) -> FlowResult:
return await self.handle_group_step(GroupType.DOMAIN, user_input)
async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> FlowResult:
return await self.handle_group_step(GroupType.SUBTRACT, user_input)
async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> FlowResult:
await self.flow.async_set_unique_id(UNIQUE_ID_TRACKED_UNTRACKED)
self.flow.abort_if_unique_id_configured()
if user_input is not None:
user_input[CONF_NAME] = "Tracked / Untracked"
async def _next(ui: dict[str, Any]) -> Step | None:
return Step.GROUP_TRACKED_UNTRACKED_AUTO if bool(ui.get("group_tracked_auto", True)) else Step.GROUP_TRACKED_UNTRACKED_MANUAL
return await self.handle_group_step(
GroupType.TRACKED_UNTRACKED,
user_input,
schema=SCHEMA_GROUP_TRACKED_UNTRACKED,
next_step=_next,
)
async def async_step_group_tracked_untracked_auto(self, user_input: dict[str, Any] | None = None) -> FlowResult:
schema = await create_schema_tracked_untracked_auto(self.flow.hass)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GROUP_TRACKED_UNTRACKED_AUTO,
schema=schema,
continue_utility_meter_options_step=True,
),
user_input,
)
async def async_step_group_tracked_untracked_manual(self, user_input: dict[str, Any] | None = None) -> FlowResult:
schema = await create_schema_group_tracked_untracked_manual(self.flow.hass, user_input)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.GROUP_TRACKED_UNTRACKED_MANUAL,
schema=schema,
continue_utility_meter_options_step=True,
),
user_input,
)
class GroupOptionsFlow(GroupFlow):
"""Handle an option flow for PowerCalc."""
def __init__(self, flow: PowercalcOptionsFlow) -> None:
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the group options flow."""
return await self.flow.async_handle_options_step(
user_input, create_schema_group_custom(self.flow.hass, self.flow.config_entry, True), Step.GROUP_CUSTOM
)
async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the group options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_GROUP_DOMAIN_OPTIONS, Step.GROUP_DOMAIN)
async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the group options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_GROUP_SUBTRACT_OPTIONS, Step.GROUP_SUBTRACT)
async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the group options flow."""
schema = SCHEMA_GROUP_TRACKED_UNTRACKED
if self.flow.sensor_config.get(CONF_GROUP_TRACKED_AUTO, True):
schema = schema.extend((await create_schema_tracked_untracked_auto(self.flow.hass)).schema)
else:
schema = schema.extend(SCHEMA_GROUP_TRACKED_UNTRACKED_MANUAL.schema)
return await self.flow.async_handle_options_step(user_input, schema, Step.GROUP_TRACKED_UNTRACKED)
def build_group_menu(self) -> list[Step]:
"""Build the group menu."""
group_type = self.flow.sensor_config.get(CONF_GROUP_TYPE, GroupType.CUSTOM)
if group_type == GroupType.DOMAIN:
return [Step.GROUP_DOMAIN]
if group_type == GroupType.SUBTRACT:
return [Step.GROUP_SUBTRACT]
if group_type == GroupType.TRACKED_UNTRACKED:
return [Step.GROUP_TRACKED_UNTRACKED]
return [Step.GROUP_CUSTOM]
@@ -0,0 +1,554 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector, translation
import voluptuous as vol
from custom_components.powercalc import (
DOMAIN,
DeviceType,
)
from custom_components.powercalc.const import (
CONF_AVAILABILITY_ENTITY,
CONF_FIXED,
CONF_MANUFACTURER,
CONF_MODE,
CONF_MODEL,
CONF_POWER,
CONF_SELF_USAGE_INCLUDED,
CONF_SUB_PROFILE,
CONF_VARIABLES,
DUMMY_ENTITY_ID,
LIBRARY_URL,
CalculationStrategy,
)
from custom_components.powercalc.discovery import (
get_power_profile_by_source_device,
get_power_profile_by_source_entity,
)
from custom_components.powercalc.flow_helper.common import FlowType, PowercalcFormStep, Step
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_UTILITY_METER_TOGGLE, build_sub_profile_schema
from custom_components.powercalc.helpers import collect_placeholders, iter_related_entity_placeholders, resolve_related_entity_placeholder
from custom_components.powercalc.power_profile.library import ModelInfo, ProfileLibrary
from custom_components.powercalc.power_profile.power_profile import DEVICE_TYPE_DOMAIN, DOMAIN_DEVICE_TYPE_MAPPING, DiscoveryBy, PowerProfile
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
CONF_CONFIRM_AUTODISCOVERED_MODEL = "confirm_autodisovered_model"
SCHEMA_POWER_AUTODISCOVERED = vol.Schema(
{vol.Optional(CONF_CONFIRM_AUTODISCOVERED_MODEL, default=True): bool},
)
SCHEMA_POWER_OPTIONS_LIBRARY = vol.Schema(
{
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_POWER_SMART_SWITCH = vol.Schema(
{
vol.Optional(CONF_POWER): vol.Coerce(float),
vol.Optional(CONF_SELF_USAGE_INCLUDED): selector.BooleanSelector(),
},
)
class LibraryFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
async def async_step_manufacturer(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Ask the user to select the manufacturer."""
async def _create_schema() -> vol.Schema:
"""Create manufacturer schema."""
library = await ProfileLibrary.factory(self.flow.hass)
manufacturers = [
selector.SelectOptionDict(value=manufacturer[0], label=manufacturer[1])
for manufacturer in await library.get_manufacturer_listing(
self._get_library_device_types(),
self._get_library_discovery_by(),
)
]
return vol.Schema(
{
vol.Required(CONF_MANUFACTURER, default=self.flow.sensor_config.get(CONF_MANUFACTURER)): selector.SelectSelector(
selector.SelectSelectorConfig(
options=manufacturers,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)
# noinspection PyTypeChecker
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.MANUFACTURER,
schema=_create_schema,
next_step=Step.MODEL,
),
user_input,
)
async def async_step_model(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Ask the user to select the model."""
def _build_model_label(model_id: str, model_name: str) -> str:
if not model_name or model_name == model_id:
return model_id
return f"{model_id} ({model_name})"
async def _validate(user_input: dict[str, Any]) -> dict[str, str]:
library = await ProfileLibrary.factory(self.flow.hass)
profile = await library.get_profile(
ModelInfo(
str(self.flow.sensor_config.get(CONF_MANUFACTURER)),
str(user_input.get(CONF_MODEL)),
),
self.flow.source_entity,
process_variables=False,
)
self.flow.selected_profile = profile
if self.flow.selected_profile and not await self.flow.selected_profile.needs_user_configuration:
await self.flow.validate_strategy_config()
return user_input
async def _create_schema() -> vol.Schema:
"""Create model schema."""
manufacturer = str(self.flow.sensor_config.get(CONF_MANUFACTURER))
library = await ProfileLibrary.factory(self.flow.hass)
models = [
selector.SelectOptionDict(value=model_id, label=_build_model_label(model_id, model_name))
for model_id, model_name in await library.get_model_listing(
manufacturer,
self._get_library_device_types(),
self._get_library_discovery_by(),
)
]
model = self.flow.selected_profile.model if self.flow.selected_profile else self.flow.sensor_config.get(CONF_MODEL)
return vol.Schema(
{
vol.Required(CONF_MODEL, description={"suggested_value": model}, default=model): selector.SelectSelector(
selector.SelectSelectorConfig(
options=models,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.MODEL,
schema=_create_schema,
next_step=Step.POST_LIBRARY,
validate_user_input=_validate,
form_kwarg={"description_placeholders": {"supported_models_link": LIBRARY_URL}},
),
user_input,
)
async def async_step_post_library(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""
Handles the logic after the user either selected manufacturer/model himself or confirmed autodiscovered.
Forwards to the next step in the flow.
"""
if not self.flow.selected_profile:
return self.flow.async_abort(reason="model_not_supported") # type:ignore # pragma: no cover
if Step.LIBRARY_CUSTOM_FIELDS not in self.flow.handled_steps and self.flow.selected_profile.has_custom_fields:
return await self.async_step_library_custom_fields()
if Step.AVAILABILITY_ENTITY not in self.flow.handled_steps and self.flow.selected_profile.discovery_by == DiscoveryBy.DEVICE:
result = await self.async_step_availability_entity()
if result:
return result
if Step.SUB_PROFILE not in self.flow.handled_steps and await self.flow.selected_profile.requires_manual_sub_profile_selection:
return await self.async_step_sub_profile()
if (
Step.SMART_SWITCH not in self.flow.handled_steps
and self.flow.selected_profile.device_type == DeviceType.SMART_SWITCH
and self.flow.selected_profile.calculation_strategy == CalculationStrategy.FIXED
):
return await self.async_step_smart_switch()
if Step.FIXED not in self.flow.handled_steps and self.flow.selected_profile.needs_fixed_config: # pragma: no cover
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_fixed() # type:ignore
if Step.LINEAR not in self.flow.handled_steps and self.flow.selected_profile.needs_linear_config:
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_linear() # type:ignore
if Step.MULTI_SWITCH not in self.flow.handled_steps and self.flow.selected_profile.calculation_strategy == CalculationStrategy.MULTI_SWITCH:
return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_multi_switch() # type:ignore
return await self.flow.flow_handlers[FlowType.GROUP].async_step_assign_groups() # type:ignore
async def async_step_library_custom_fields(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for custom fields."""
async def _process_user_input(user_input: dict[str, Any]) -> dict[str, Any]:
return {CONF_VARIABLES: user_input}
form_kwarg: dict[str, Any] | None = None
if self.flow.selected_profile and self.flow.selected_profile.documentation_url:
form_kwarg = {
"description_placeholders": {
"documentation_url": self.flow.selected_profile.documentation_url,
},
}
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.LIBRARY_CUSTOM_FIELDS,
schema=build_dynamic_field_schema(
self.flow.hass,
self.flow.selected_profile, # type: ignore
self.flow.source_entity,
),
next_step=Step.POST_LIBRARY,
validate_user_input=_process_user_input,
form_kwarg=form_kwarg,
),
user_input,
)
async def async_step_sub_profile(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Handle the flow for sub profile selection."""
assert self.flow.selected_profile is not None
async def _validate(user_input: dict[str, Any]) -> dict[str, str]:
return {CONF_MODEL: f"{self.flow.sensor_config.get(CONF_MODEL)}/{user_input.get(CONF_SUB_PROFILE)}"}
library = await ProfileLibrary.factory(self.flow.hass)
profile = await library.get_profile(
ModelInfo(
str(self.flow.sensor_config.get(CONF_MANUFACTURER)),
str(self.flow.sensor_config.get(CONF_MODEL)),
),
self.flow.source_entity,
process_variables=False,
)
remarks = profile.config_flow_sub_profile_remarks
if remarks:
remarks = "\n\n" + remarks
step = Step.SUB_PROFILE_PER_DEVICE if self.flow.selected_profile.discovery_by == DiscoveryBy.DEVICE else Step.SUB_PROFILE
return await self.flow.handle_form_step(
PowercalcFormStep(
step=step,
schema=await build_sub_profile_schema(profile, self.flow.selected_sub_profile),
next_step=Step.POWER_ADVANCED,
validate_user_input=_validate,
form_kwarg={
"description_placeholders": {
"entity_id": self.flow.source_entity_id,
"remarks": remarks,
},
},
),
user_input,
)
async def async_step_sub_profile_per_device(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
return await self.async_step_sub_profile(user_input)
async def async_step_smart_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Asks the user for the power of connect appliance for the smart switch."""
if self.flow.selected_profile and not self.flow.selected_profile.needs_fixed_config:
return self.flow.persist_config_entry()
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
return {
CONF_SELF_USAGE_INCLUDED: user_input.get(CONF_SELF_USAGE_INCLUDED),
CONF_MODE: CalculationStrategy.FIXED,
CONF_FIXED: {CONF_POWER: user_input.get(CONF_POWER, 0)},
}
self_usage_on = self.flow.selected_profile.standby_power_on if self.flow.selected_profile else 0
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.SMART_SWITCH,
schema=SCHEMA_POWER_SMART_SWITCH,
validate_user_input=_validate,
next_step=Step.POWER_ADVANCED,
form_kwarg={"description_placeholders": {"self_usage_power": str(self_usage_on)}},
),
user_input,
)
async def async_step_availability_entity(self, user_input: dict[str, Any] | None = None) -> FlowResult | None:
"""Handle the flow for availability entity."""
# Auto-resolve availability entity from profile placeholders
auto_entity = self._resolve_availability_entity()
if auto_entity:
self.flow.sensor_config[CONF_AVAILABILITY_ENTITY] = auto_entity
self.flow.handled_steps.append(Step.AVAILABILITY_ENTITY)
return None
domains = DEVICE_TYPE_DOMAIN[self.flow.selected_profile.device_type] # type: ignore
entity_selector = self.flow.create_device_entity_selector(
list(domains) if isinstance(domains, set) else [domains],
)
try:
first_entity = entity_selector.config["include_entities"][0]
except IndexError:
# Skip step if no entities are available
self.flow.handled_steps.append(Step.AVAILABILITY_ENTITY)
return None
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.AVAILABILITY_ENTITY,
schema=vol.Schema(
{
vol.Optional(CONF_AVAILABILITY_ENTITY, default=first_entity): entity_selector,
},
),
next_step=Step.POST_LIBRARY,
),
user_input,
)
def _resolve_availability_entity(self) -> str | None:
"""Try to auto-resolve an availability entity from profile placeholders."""
profile = self.flow.selected_profile
device_entry = self.flow.source_entity.device_entry if self.flow.source_entity else None
if not profile or not device_entry:
return None
for placeholder in iter_related_entity_placeholders(collect_placeholders(profile.json_data)):
entity = resolve_related_entity_placeholder(
self.flow.hass,
placeholder,
source_entity=self.flow.source_entity,
)
if entity:
return entity
return None
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:
return None
if self.flow.source_entity:
return DOMAIN_DEVICE_TYPE_MAPPING.get(self.flow.source_entity.domain, set())
return None # pragma: no cover
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
class LibraryConfigFlow(LibraryFlow):
def __init__(self, flow: PowercalcConfigFlow) -> None:
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
async def async_step_library_multi_profile(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult | ConfigFlowResult:
"""This step gets executed when multiple profiles are found for the source entity."""
if user_input is not None:
selected_model: str = user_input.get(CONF_MODEL) # type: ignore
selected_profile = self.flow.discovered_profiles.get(selected_model)
if selected_profile is None: # pragma: no cover
return self.flow.async_abort(reason="invalid_profile")
self.flow.selected_profile = selected_profile
self.flow.sensor_config.update(
{
CONF_MANUFACTURER: selected_profile.manufacturer,
CONF_MODEL: selected_profile.model,
},
)
return await self.async_step_post_library(user_input)
schema = vol.Schema(
{
vol.Required(CONF_MODEL): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=profile.unique_id,
label=profile.model,
)
for profile in self.flow.discovered_profiles.values()
],
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)
manufacturer = str(self.flow.sensor_config.get(CONF_MANUFACTURER))
model = str(self.flow.sensor_config.get(CONF_MODEL))
return self.flow.async_show_form(
step_id=Step.LIBRARY_MULTI_PROFILE,
data_schema=schema,
description_placeholders={
"library_link": f"{LIBRARY_URL}/?manufacturer={manufacturer}",
"manufacturer": manufacturer,
"model": model,
},
last_step=False,
)
async def async_step_library(
self,
user_input: dict[str, Any] | None = None,
) -> FlowResult:
"""Try to autodiscover manufacturer/model first.
Ask the user to confirm this or forward to manual library selection.
"""
if user_input is not None:
return await self._handle_library_confirmation(user_input)
await self._async_autodiscover_profile()
if not self.flow.selected_profile:
return await self.async_step_manufacturer()
return self._show_autodiscovered_profile_form()
async def _handle_library_confirmation(self, user_input: dict[str, Any]) -> FlowResult:
"""Handle the user's response to an autodiscovered library profile."""
if not user_input.get(CONF_CONFIRM_AUTODISCOVERED_MODEL) or not self.flow.selected_profile:
return await self.async_step_manufacturer()
self.flow.sensor_config.update(
{
CONF_MANUFACTURER: self.flow.selected_profile.manufacturer,
CONF_MODEL: self.flow.selected_profile.model,
},
)
return await self.async_step_post_library(user_input)
async def _async_autodiscover_profile(self) -> None:
"""Populate the selected profile from the source entity when possible."""
if not self.flow.source_entity or not self.flow.source_entity.entity_entry or self.flow.selected_profile is not None:
return
self.flow.selected_profile = await get_power_profile_by_source_entity(self.flow.hass, self.flow.source_entity)
if self.flow.selected_profile is None and self.flow.source_entity.device_entry:
self.flow.selected_profile = await get_power_profile_by_source_device(self.flow.hass, self.flow.source_entity)
def _show_autodiscovered_profile_form(self) -> FlowResult:
"""Show the confirmation form for an autodiscovered library profile."""
profile = self.flow.selected_profile
assert profile is not None
return cast(
FlowResult,
self.flow.async_show_form(
step_id=Step.LIBRARY,
description_placeholders=self._build_library_description_placeholders(profile),
data_schema=SCHEMA_POWER_AUTODISCOVERED,
errors={},
last_step=False,
),
)
def _build_library_description_placeholders(self, profile: PowerProfile) -> dict[str, Any]:
"""Build the placeholders for the autodiscovered profile confirmation form."""
return {
"remarks": self._build_library_remarks(profile),
"manufacturer": profile.manufacturer,
"model": profile.model,
"source": self._get_profile_source(profile),
}
def _build_library_remarks(self, profile: PowerProfile) -> str | None:
"""Build the remarks text for the autodiscovered profile confirmation form."""
remarks = self._get_conditional_remarks()
if remarks:
remarks = "\n\n" + remarks
if profile.documentation_url:
return (remarks or "") + f"\n\n[Documentation]({profile.documentation_url})"
return remarks
def _get_profile_source(self, profile: PowerProfile) -> str:
"""Build the autodiscovery source description."""
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:
return f"{translations.get(f'component.{DOMAIN}.common.source_device')}: {self.flow.source_entity.device_entry.name}"
return f"{translations.get(f'component.{DOMAIN}.common.source_entity')}: {self.flow.source_entity_id}"
def _get_conditional_remarks(self) -> str | None:
"""Get discovery remarks, only showing them if required entities are missing."""
profile = self.flow.selected_profile
if not profile:
return None # pragma: no cover
remarks = profile.config_flow_discovery_remarks
if not remarks:
return None
# Check if all entity_by_* placeholders can be resolved from the device
device_entry = self.flow.source_entity.device_entry if self.flow.source_entity else None
if not device_entry:
return remarks
related_placeholders = iter_related_entity_placeholders(collect_placeholders(profile.json_data))
all_resolved = all(
resolve_related_entity_placeholder(
self.flow.hass,
placeholder,
source_entity=self.flow.source_entity,
)
for placeholder in related_placeholders
)
return None if all_resolved else remarks
class LibraryOptionsFlow(LibraryFlow):
def __init__(self, flow: PowercalcOptionsFlow) -> None:
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def async_step_library_options(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the basic options flow."""
self.flow.is_library_flow = True
self.flow.selected_sub_profile = self.flow.selected_profile.sub_profile # type: ignore
if user_input is not None:
return await self.async_step_manufacturer()
return self.flow.async_show_form(
step_id=Step.LIBRARY_OPTIONS,
description_placeholders={
"manufacturer": self.flow.selected_profile.manufacturer, # type: ignore
"model": self.flow.selected_profile.model, # type: ignore
},
last_step=False,
)
@@ -0,0 +1,62 @@
"""Real-power logic for the config flow."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import CONF_DEVICE, CONF_ENTITY_ID, CONF_NAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
import voluptuous as vol
from custom_components.powercalc import SensorType
from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step
from custom_components.powercalc.flow_helper.schema import SCHEMA_UTILITY_METER_TOGGLE
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcConfigFlow, PowercalcOptionsFlow
SCHEMA_REAL_POWER_OPTIONS = vol.Schema(
{
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(device_class=SensorDeviceClass.POWER),
),
vol.Optional(CONF_DEVICE): selector.DeviceSelector(),
},
)
SCHEMA_REAL_POWER = vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
**SCHEMA_REAL_POWER_OPTIONS.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
).extend(SCHEMA_REAL_POWER_OPTIONS.schema)
class RealPowerConfigFlow:
def __init__(self, flow: PowercalcConfigFlow) -> None:
self.flow: PowercalcConfigFlow = flow
async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for real power sensor"""
self.flow.selected_sensor_type = SensorType.REAL_POWER
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.REAL_POWER,
schema=SCHEMA_REAL_POWER,
next_step=Step.ENERGY_OPTIONS,
),
user_input,
)
class RealPowerOptionsFlow:
def __init__(self, flow: PowercalcOptionsFlow) -> None:
self.flow: PowercalcOptionsFlow = flow
async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the real power options flow."""
return await self.flow.async_handle_options_step(user_input, SCHEMA_REAL_POWER_OPTIONS, Step.REAL_POWER)
@@ -0,0 +1,436 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITIES, CONF_ENTITY_ID, CONF_ID, CONF_NAME, CONF_PATH, Platform
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError
import voluptuous as vol
from custom_components.powercalc.common import create_source_entity
from custom_components.powercalc.const import (
CONF_AUTOSTART,
CONF_CALCULATION_ENABLED_CONDITION,
CONF_CALIBRATE,
CONF_CREATE_ENERGY_SENSOR,
CONF_CREATE_UTILITY_METERS,
CONF_GAMMA_CURVE,
CONF_IGNORE_UNAVAILABLE_STATE,
CONF_MAX_POWER,
CONF_MIN_POWER,
CONF_MODE,
CONF_MULTIPLY_FACTOR,
CONF_MULTIPLY_FACTOR_STANDBY,
CONF_PLAYBOOKS,
CONF_POWER,
CONF_POWER_OFF,
CONF_POWER_TEMPLATE,
CONF_REPEAT,
CONF_STANDBY_POWER,
CONF_STATE,
CONF_STATE_TRIGGER,
CONF_STATES_POWER,
CONF_UNAVAILABLE_POWER,
DUMMY_ENTITY_ID,
CalculationStrategy,
SensorType,
)
from custom_components.powercalc.flow_helper.common import FlowType, PowercalcFormStep, Step, fill_schema_defaults
from custom_components.powercalc.flow_helper.flows.global_configuration import get_global_powercalc_config
from custom_components.powercalc.flow_helper.flows.library import SCHEMA_POWER_OPTIONS_LIBRARY, SCHEMA_POWER_SMART_SWITCH
from custom_components.powercalc.flow_helper.schema import (
SCHEMA_ENERGY_SENSOR_TOGGLE,
SCHEMA_SENSOR_ENERGY_OPTIONS,
SCHEMA_UTILITY_METER_TOGGLE,
)
from custom_components.powercalc.power_profile.power_profile import DeviceType
from custom_components.powercalc.strategy.wled import CONFIG_SCHEMA as SCHEMA_POWER_WLED
if TYPE_CHECKING:
from custom_components.powercalc.config_flow import PowercalcCommonFlow, PowercalcConfigFlow, PowercalcOptionsFlow
SCHEMA_POWER_ADVANCED = vol.Schema(
{
vol.Optional(CONF_CALCULATION_ENABLED_CONDITION): selector.TemplateSelector(),
vol.Optional(CONF_IGNORE_UNAVAILABLE_STATE): selector.BooleanSelector(),
vol.Optional(CONF_UNAVAILABLE_POWER): vol.Coerce(float),
vol.Optional(CONF_MULTIPLY_FACTOR): vol.Coerce(float),
vol.Optional(CONF_MULTIPLY_FACTOR_STANDBY): selector.BooleanSelector(),
},
)
SCHEMA_POWER_BASE = vol.Schema(
{
vol.Optional(CONF_NAME): selector.TextSelector(),
},
)
SCHEMA_POWER_OPTIONS = vol.Schema(
{
vol.Optional(CONF_STANDBY_POWER): vol.Coerce(float),
**SCHEMA_ENERGY_SENSOR_TOGGLE.schema,
**SCHEMA_UTILITY_METER_TOGGLE.schema,
},
)
SCHEMA_POWER_FIXED = vol.Schema(
{
vol.Optional(CONF_POWER): vol.Coerce(float),
vol.Optional(CONF_POWER_TEMPLATE): selector.TemplateSelector(),
vol.Optional(CONF_STATES_POWER): selector.ObjectSelector(),
},
)
SCHEMA_POWER_LINEAR = vol.Schema(
{
vol.Optional(CONF_MIN_POWER): vol.Coerce(float),
vol.Optional(CONF_MAX_POWER): vol.Coerce(float),
vol.Optional(CONF_GAMMA_CURVE): vol.Coerce(float),
vol.Optional(CONF_CALIBRATE): selector.ObjectSelector(),
},
)
SCHEMA_POWER_MULTI_SWITCH_MANUAL = vol.Schema(
{
vol.Required(CONF_POWER): vol.Coerce(float),
vol.Required(CONF_POWER_OFF): vol.Coerce(float),
},
)
STRATEGY_SCHEMAS: dict[CalculationStrategy, vol.Schema] = {
CalculationStrategy.FIXED: SCHEMA_POWER_FIXED,
CalculationStrategy.WLED: SCHEMA_POWER_WLED,
}
STRATEGY_SELECTOR = selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
CalculationStrategy.FIXED,
CalculationStrategy.LINEAR,
CalculationStrategy.MULTI_SWITCH,
CalculationStrategy.PLAYBOOK,
CalculationStrategy.WLED,
CalculationStrategy.LUT,
],
mode=selector.SelectSelectorMode.DROPDOWN,
),
)
STRATEGY_STEP_MAPPING: dict[CalculationStrategy, Step] = {
CalculationStrategy.FIXED: Step.FIXED,
CalculationStrategy.LINEAR: Step.LINEAR,
CalculationStrategy.MULTI_SWITCH: Step.MULTI_SWITCH,
CalculationStrategy.PLAYBOOK: Step.PLAYBOOK,
CalculationStrategy.WLED: Step.WLED,
}
class VirtualPowerFlow:
def __init__(self, flow: PowercalcCommonFlow) -> None:
self.flow = flow
async def create_strategy_schema(self) -> vol.Schema:
"""Get the config schema for a given power calculation strategy."""
if not self.flow.strategy:
raise ValueError("No strategy selected") # pragma: no cover
create_schema_func = f"create_schema_{self.flow.strategy.lower()}"
if hasattr(self, create_schema_func):
return await getattr(self, create_schema_func)() # type: ignore
return STRATEGY_SCHEMAS[self.flow.strategy]
async def create_schema_linear(self) -> vol.Schema:
"""Create the config schema for linear strategy."""
return SCHEMA_POWER_LINEAR.extend( # type: ignore
{
vol.Optional(CONF_ATTRIBUTE): selector.AttributeSelector(
selector.AttributeSelectorConfig(
entity_id=self.flow.source_entity_id, # type: ignore
hide_attributes=[],
),
),
},
)
async def create_schema_multi_switch(self) -> vol.Schema:
"""Create the config schema for multi switch strategy."""
switch_domains = [str(Platform.SWITCH), str(Platform.LIGHT), str(Platform.COVER)]
if self.flow.source_entity and self.flow.source_entity.device_entry:
entity_selector = self.flow.create_device_entity_selector(switch_domains, multiple=True)
else:
entity_selector = selector.EntitySelector(
selector.EntitySelectorConfig(
domain=switch_domains,
multiple=True,
),
)
default_entities = entity_selector.config.get("include_entities", [])
schema = vol.Schema({vol.Optional(CONF_ENTITIES, default=default_entities): entity_selector})
if not self.flow.is_library_flow:
schema = schema.extend(SCHEMA_POWER_MULTI_SWITCH_MANUAL.schema)
return schema
async def create_schema_playbook(self) -> vol.Schema:
"""Create the config schema for playbook strategy."""
base_path = Path(self.flow.hass.config.path("powercalc/playbooks"))
playbook_files = [str(p.relative_to(base_path)) for p in base_path.rglob("*") if p.is_file()]
return vol.Schema(
{
vol.Optional(CONF_PLAYBOOKS): selector.ObjectSelector(
{
"multiple": True,
"description_field": CONF_PATH,
"label_field": CONF_ID,
"fields": {
CONF_ID: {
"required": True,
"selector": {"text": None},
},
CONF_PATH: {
"required": True,
"selector": {"select": {"options": playbook_files, "mode": "dropdown", "custom_value": True}},
},
},
},
),
vol.Optional(CONF_REPEAT): selector.BooleanSelector(),
vol.Optional(CONF_AUTOSTART): selector.TextSelector(),
vol.Optional(CONF_STATE_TRIGGER): selector.ObjectSelector(),
},
)
async def handle_strategy_step(
self,
strategy: CalculationStrategy,
user_input: dict[str, Any] | None = None,
validate: Callable[[dict[str, Any]], None] | None = None,
) -> FlowResult:
self.flow.strategy = strategy
async def _validate(user_input: dict[str, Any]) -> dict[str, Any]:
if validate:
validate(user_input)
# Convert states_power from dict to list to preserve order
if CONF_STATES_POWER in user_input and isinstance(user_input[CONF_STATES_POWER], dict):
user_input[CONF_STATES_POWER] = [{CONF_STATE: state, CONF_POWER: power} for state, power in user_input[CONF_STATES_POWER].items()]
await self.flow.validate_strategy_config({strategy: user_input})
return {strategy: user_input}
schema = await self.create_strategy_schema()
description_placeholders = {}
if strategy == CalculationStrategy.WLED:
description_placeholders = {
"docs_uri": "https://docs.powercalc.nl/strategies/wled/",
}
return await self.flow.handle_form_step(
PowercalcFormStep(
step=STRATEGY_STEP_MAPPING[strategy],
schema=schema,
next_step=Step.ASSIGN_GROUPS,
validate_user_input=_validate,
form_kwarg={"description_placeholders": description_placeholders},
),
user_input,
)
async def async_step_power_advanced(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for advanced options."""
if self.flow.is_options_flow:
return self.flow.persist_config_entry() # pragma: no cover
if user_input is not None or self.flow.skip_advanced_step:
self.flow.sensor_config.update(user_input or {})
if self.flow.sensor_config.get(CONF_CREATE_UTILITY_METERS):
return await self.flow.async_step_utility_meter_options()
return self.flow.persist_config_entry()
schema = SCHEMA_POWER_ADVANCED
if self.flow.sensor_config.get(CONF_CREATE_ENERGY_SENSOR):
schema = schema.extend(SCHEMA_SENSOR_ENERGY_OPTIONS.schema)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.POWER_ADVANCED,
schema=fill_schema_defaults(
schema,
get_global_powercalc_config(self.flow),
),
),
)
class VirtualPowerConfigFlow(VirtualPowerFlow):
def __init__(self, flow: PowercalcConfigFlow) -> None:
super().__init__(flow)
self.flow: PowercalcConfigFlow = flow
def create_schema_virtual_power(
self,
) -> vol.Schema:
"""Create the config schema for virtual power sensor."""
schema = vol.Schema(
{
vol.Optional(CONF_ENTITY_ID): self.flow.create_source_entity_selector(),
},
).extend(SCHEMA_POWER_BASE.schema)
if not self.flow.is_library_flow:
schema = schema.extend(
{
vol.Optional(
CONF_MODE,
default=CalculationStrategy.FIXED,
): STRATEGY_SELECTOR,
},
)
options_schema = SCHEMA_POWER_OPTIONS
else:
options_schema = SCHEMA_POWER_OPTIONS_LIBRARY
power_options = fill_schema_defaults(
options_schema,
get_global_powercalc_config(self.flow),
)
return schema.extend(power_options.schema) # type: ignore
async def async_step_virtual_power(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for virtual power sensor."""
errors: dict[str, str] = {}
if user_input is not None:
selected_strategy = CalculationStrategy(
user_input.get(CONF_MODE) or CalculationStrategy.LUT,
)
entity_id = user_input.get(CONF_ENTITY_ID)
if selected_strategy is not CalculationStrategy.PLAYBOOK and user_input.get(CONF_NAME) is None and entity_id is None:
errors[CONF_ENTITY_ID] = "entity_mandatory"
if not errors:
self.flow.source_entity_id = str(entity_id or DUMMY_ENTITY_ID)
self.flow.source_entity = await create_source_entity(
self.flow.source_entity_id,
self.flow.hass,
)
self.flow.name = user_input.get(CONF_NAME) or self.flow.source_entity.name
self.flow.selected_sensor_type = SensorType.VIRTUAL_POWER
self.flow.sensor_config.update(user_input)
return await self.forward_to_strategy_step(selected_strategy)
return self.flow.async_show_form( # type: ignore
step_id=Step.VIRTUAL_POWER,
data_schema=self.create_schema_virtual_power(),
description_placeholders={
"doc_uri_states_power": "https://docs.powercalc.nl/strategies/fixed/#power-per-state",
},
errors=errors,
last_step=False,
)
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for fixed sensor."""
return await self.handle_strategy_step(CalculationStrategy.FIXED, user_input)
async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for fixed sensor."""
return await self.handle_strategy_step(CalculationStrategy.LINEAR, user_input)
async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for multi switch strategy."""
return await self.handle_strategy_step(CalculationStrategy.MULTI_SWITCH, user_input)
async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for WLED sensor."""
return await self.handle_strategy_step(CalculationStrategy.WLED, user_input)
async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the flow for playbook sensor."""
def _validate(user_input: dict[str, Any]) -> None:
if user_input.get(CONF_PLAYBOOKS) is None or len(user_input.get(CONF_PLAYBOOKS)) == 0: # type: ignore
raise SchemaFlowError("playbook_mandatory")
return await self.handle_strategy_step(CalculationStrategy.PLAYBOOK, user_input, _validate)
async def forward_to_strategy_step(self, strategy: CalculationStrategy) -> FlowResult:
"""Forward to the next step based on the selected strategy."""
step = STRATEGY_STEP_MAPPING.get(strategy)
if step is None:
return await self.flow.flow_handlers[FlowType.LIBRARY].async_step_library() # type:ignore
method = getattr(self.flow, f"async_step_{step}")
return await method() # type: ignore
class VirtualPowerOptionsFlow(VirtualPowerFlow):
def __init__(self, flow: PowercalcOptionsFlow) -> None:
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def build_strategy_config(
self,
user_input: dict[str, Any],
) -> dict[str, Any]:
"""Build the config dict needed for the configured strategy."""
strategy_schema = await self.create_strategy_schema()
strategy_options: dict[str, Any] = {}
for key in strategy_schema.schema:
if user_input.get(key) is None:
continue
strategy_options[str(key)] = user_input.get(key)
# Convert states_power from dict to list to preserve order
if CONF_STATES_POWER in strategy_options and isinstance(strategy_options[CONF_STATES_POWER], dict):
strategy_options[CONF_STATES_POWER] = [
{CONF_STATE: state, CONF_POWER: power} for state, power in strategy_options[CONF_STATES_POWER].items()
]
return strategy_options
async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the basic options flow."""
return await self.async_handle_strategy_options_step(user_input)
async def async_handle_strategy_options_step(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the option processing for the selected strategy."""
step = STRATEGY_STEP_MAPPING.get(self.flow.strategy or CalculationStrategy.FIXED, Step.FIXED)
schema = await self.create_strategy_schema()
if self.flow.selected_profile and self.flow.selected_profile.device_type == DeviceType.SMART_SWITCH:
schema = SCHEMA_POWER_SMART_SWITCH
strategy_options = self.flow.sensor_config.get(str(self.flow.strategy)) or {}
merged_options = {
**self.flow.sensor_config,
**{k: v for k, v in strategy_options.items() if k not in self.flow.sensor_config},
}
# Convert states_power from list to dict for display in ObjectSelector
if CONF_STATES_POWER in merged_options and isinstance(merged_options[CONF_STATES_POWER], list):
merged_options[CONF_STATES_POWER] = {item[CONF_STATE]: item[CONF_POWER] for item in merged_options[CONF_STATES_POWER]}
schema = fill_schema_defaults(schema, merged_options)
return await self.flow.async_handle_options_step(user_input, schema, step)
@@ -0,0 +1,124 @@
from homeassistant.components.utility_meter import CONF_METER_TYPE, METER_TYPES
from homeassistant.const import UnitOfPower
from homeassistant.helpers import selector
from homeassistant.helpers.selector import NumberSelector, NumberSelectorConfig, NumberSelectorMode
import voluptuous as vol
from custom_components.powercalc.const import (
CONF_CREATE_ENERGY_SENSOR,
CONF_CREATE_UTILITY_METERS,
CONF_ENERGY_FILTER_OUTLIER_ENABLED,
CONF_ENERGY_FILTER_OUTLIER_MAX,
CONF_ENERGY_INTEGRATION_METHOD,
CONF_ENERGY_SENSOR_UNIT_PREFIX,
CONF_SUB_PROFILE,
CONF_UTILITY_METER_NET_CONSUMPTION,
CONF_UTILITY_METER_OFFSET,
CONF_UTILITY_METER_TARIFFS,
CONF_UTILITY_METER_TYPES,
ENERGY_INTEGRATION_METHOD_LEFT,
ENERGY_INTEGRATION_METHODS,
UnitPrefix,
)
from custom_components.powercalc.power_profile.power_profile import PowerProfile
SCHEMA_UTILITY_METER_TOGGLE = vol.Schema(
{
vol.Optional(CONF_CREATE_UTILITY_METERS, default=False): selector.BooleanSelector(),
},
)
SCHEMA_ENERGY_SENSOR_TOGGLE = vol.Schema(
{
vol.Optional(CONF_CREATE_ENERGY_SENSOR, default=True): selector.BooleanSelector(),
},
)
SCHEMA_ENERGY_OPTIONS = vol.Schema(
{
vol.Optional(
CONF_ENERGY_INTEGRATION_METHOD,
default=ENERGY_INTEGRATION_METHOD_LEFT,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=ENERGY_INTEGRATION_METHODS,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
vol.Optional(CONF_ENERGY_SENSOR_UNIT_PREFIX): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(value=UnitPrefix.KILO, label="k (kilo)"),
selector.SelectOptionDict(value=UnitPrefix.MEGA, label="M (mega)"),
selector.SelectOptionDict(value=UnitPrefix.GIGA, label="G (giga)"),
selector.SelectOptionDict(value=UnitPrefix.TERA, label="T (tera)"),
],
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)
SCHEMA_SENSOR_ENERGY_OPTIONS = SCHEMA_ENERGY_OPTIONS.extend(
vol.Schema(
{
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_ENABLED, default=False): selector.BooleanSelector(),
vol.Optional(CONF_ENERGY_FILTER_OUTLIER_MAX): NumberSelector(
NumberSelectorConfig(mode=NumberSelectorMode.BOX, unit_of_measurement=UnitOfPower.WATT),
),
},
).schema,
)
SCHEMA_UTILITY_METER_OPTIONS = vol.Schema(
{
vol.Required(CONF_UTILITY_METER_TYPES): selector.SelectSelector(
selector.SelectSelectorConfig(
options=METER_TYPES,
translation_key=CONF_METER_TYPE,
multiple=True,
),
),
vol.Optional(CONF_UTILITY_METER_TARIFFS, default=[]): selector.SelectSelector(
selector.SelectSelectorConfig(options=[], custom_value=True, multiple=True),
),
vol.Optional(CONF_UTILITY_METER_NET_CONSUMPTION, default=False): selector.BooleanSelector(),
vol.Required(CONF_UTILITY_METER_OFFSET, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=28,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="days",
),
),
},
)
async def build_sub_profile_schema(
profile: PowerProfile,
selected_sub_profile: str | None,
) -> vol.Schema:
"""Create sub profile schema."""
sub_profiles = [
selector.SelectOptionDict(
value=sub_profile[0],
label=sub_profile[1]["name"] if "name" in sub_profile[1] else sub_profile[0],
)
for sub_profile in await profile.get_sub_profiles()
]
return vol.Schema(
{
vol.Required(
CONF_SUB_PROFILE,
description={"suggested_value": selected_sub_profile},
default=selected_sub_profile,
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=sub_profiles,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)
@@ -0,0 +1,381 @@
from __future__ import annotations
from collections.abc import Callable, Iterable, Sequence
from enum import StrEnum
import re
from typing import Protocol, cast
from homeassistant.components.group import DOMAIN as GROUP_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
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_component import EntityComponent
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType
import voluptuous as vol
from custom_components.powercalc.const import (
CONF_ALL,
CONF_AND,
CONF_AREA,
CONF_CATEGORY,
CONF_FILTER,
CONF_FLOOR,
CONF_GROUP,
CONF_LABEL,
CONF_NOT,
CONF_OR,
CONF_TEMPLATE,
CONF_WILDCARD,
)
from custom_components.powercalc.errors import SensorConfigurationError
class FilterOperator(StrEnum):
AND = "and"
OR = "or"
NOT = "not"
FILTER_CONFIG = vol.Schema(
{
vol.Optional(CONF_ALL): None,
vol.Optional(CONF_AREA): vol.Any(vol.All(cv.ensure_list, [cv.string]), cv.string),
vol.Optional(CONF_CATEGORY): vol.Any(vol.All(cv.ensure_list, [cv.string]), cv.string),
vol.Optional(CONF_FLOOR): vol.Any(vol.All(cv.ensure_list, [cv.string]), cv.string),
vol.Optional(CONF_GROUP): vol.Any(vol.All(cv.ensure_list, [cv.entity_id]), cv.entity_id),
vol.Optional(CONF_DOMAIN): vol.Any(vol.All(cv.ensure_list, [cv.string]), cv.string),
vol.Optional(CONF_LABEL): vol.Any(vol.All(cv.ensure_list, [cv.string]), cv.string),
vol.Optional(CONF_TEMPLATE): cv.template,
vol.Optional(CONF_WILDCARD): cv.string,
},
)
def create_composite_filter(
filter_configs: ConfigType | list[ConfigType],
hass: HomeAssistant,
filter_operator: FilterOperator,
) -> EntityFilter:
"""Create filter class."""
filters: list[EntityFilter] = []
if CONF_FILTER in filter_configs and isinstance(filter_configs, dict):
filter_configs.update(filter_configs[CONF_FILTER])
filter_configs.pop(CONF_FILTER)
if not isinstance(filter_configs, list):
filter_configs = [{key: value} for key, value in filter_configs.items()]
for filter_config in filter_configs:
for key, val in filter_config.items():
filter_instance = create_filter(key, val, hass)
filters.append(filter_instance)
if filter_operator == FilterOperator.NOT:
return NotFilter(CompositeFilter(filters))
return CompositeFilter(filters, filter_operator)
def create_filter(
filter_type: str,
filter_config: ConfigType | str | list | Template,
hass: HomeAssistant,
) -> EntityFilter:
filter_mapping: dict[str, Callable[[], EntityFilter]] = {
CONF_DOMAIN: lambda: DomainFilter(filter_config), # type: ignore
CONF_AREA: lambda: AreaFilter(hass, filter_config), # type: ignore
CONF_CATEGORY: lambda: CategoryFilter(filter_config), # type: ignore
CONF_FLOOR: lambda: FloorFilter(hass, filter_config), # type: ignore
CONF_LABEL: lambda: LabelFilter(hass, filter_config), # type: ignore
CONF_WILDCARD: lambda: WildcardFilter(filter_config), # type: ignore
CONF_GROUP: lambda: GroupFilter(hass, filter_config), # type: ignore
CONF_TEMPLATE: lambda: TemplateFilter(hass, filter_config), # type: ignore
CONF_ALL: lambda: NullFilter(),
CONF_OR: lambda: create_composite_filter(filter_config, hass, FilterOperator.OR), # type: ignore
CONF_AND: lambda: create_composite_filter(filter_config, hass, FilterOperator.AND), # type: ignore
CONF_NOT: lambda: create_composite_filter(filter_config, hass, FilterOperator.NOT), # type: ignore
}
return filter_mapping.get(filter_type, lambda: NullFilter())()
async def get_filtered_entity_list(
hass: HomeAssistant,
entity_filter: EntityFilter,
) -> list[entity_registry.RegistryEntry]:
"""Get a listing of entities from HA registry based on the given filter."""
entity_reg = entity_registry.async_get(hass)
return [entry for entry in entity_reg.entities.values() if entity_filter.is_valid(entry) and not entry.disabled]
class EntityFilter(Protocol):
def is_valid(self, entity: RegistryEntry) -> bool:
"""Return True when the entity should be included, False when it should be discarded."""
class DomainFilter(EntityFilter):
def __init__(self, domain: str | Iterable[str]) -> None:
self.domains = {domain} if isinstance(domain, str) else set(domain)
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.domain in self.domains
class GroupFilter(EntityFilter):
def __init__(self, hass: HomeAssistant, group_id: str | Iterable[str]) -> None:
group_ids = [group_id] if isinstance(group_id, str) else group_id
filters = []
for single_group_id in group_ids:
domain = split_entity_id(single_group_id)[0]
filter_instance = LightGroupFilter(hass, single_group_id) if domain == LIGHT_DOMAIN else StandardGroupFilter(hass, single_group_id)
filters.append(filter_instance)
self.filter = CompositeFilter(filters, FilterOperator.OR) if len(filters) > 1 else filters[0]
def is_valid(self, entity: RegistryEntry) -> bool:
return self.filter.is_valid(entity)
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")
self.entity_ids = group_state.attributes.get(ATTR_ENTITY_ID) or []
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.entity_id in self.entity_ids
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,
)
if light_group is None or light_group.platform.platform_name != GROUP_DOMAIN:
raise SensorConfigurationError(f"Light group {group_id} not found")
self.entity_ids = self.find_all_entity_ids_recursively(hass, group_id, [])
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.entity_id in self.entity_ids
def find_all_entity_ids_recursively(
self,
hass: HomeAssistant,
group_entity_id: str,
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,
)
entity_ids: list[str] = light_group.extra_state_attributes.get(ATTR_ENTITY_ID) # type: ignore
for entity_id in entity_ids:
registry_entry = entity_reg.async_get(entity_id)
if registry_entry is None:
continue
if registry_entry.platform == GROUP_DOMAIN:
self.find_all_entity_ids_recursively(
hass,
registry_entry.entity_id,
all_entity_ids,
)
all_entity_ids.append(entity_id)
return all_entity_ids
class NullFilter(EntityFilter):
def is_valid(self, entity: RegistryEntry) -> bool:
return True
class WildcardFilter(EntityFilter):
def __init__(self, pattern: str) -> None:
self.regex = self.create_regex(pattern)
def is_valid(self, entity: RegistryEntry) -> bool:
return re.search(self.regex, entity.entity_id) is not None
@staticmethod
def create_regex(pattern: str) -> str:
pattern = pattern.replace("?", ".")
pattern = pattern.replace("*", ".*")
return "^" + pattern + "$"
class TemplateFilter(EntityFilter):
def __init__(self, hass: HomeAssistant, template: Template) -> None:
template.hass = hass
self.entity_ids = template.async_render()
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.entity_id in self.entity_ids
class LabelFilter(EntityFilter):
def __init__(self, hass: HomeAssistant, label: str | Iterable[str]) -> None:
self._hass = hass
labels = [label] if isinstance(label, str) else label
self.labels = [self._get_label_id(label) for label in labels]
self.devices: set[str] = set()
device_reg = device_registry.async_get(hass)
for label_id in self.labels:
self.devices.update([device.id for device in device_registry.async_entries_for_label(device_reg, label_id)])
def _get_label_id(self, label: str) -> str:
label_reg = label_registry.async_get(self._hass)
label_entry = label_reg.async_get_label(label)
if label_entry:
return label_entry.label_id
label_entry = label_reg.async_get_label_by_name(str(label))
if label_entry:
return label_entry.label_id
raise SensorConfigurationError(
f"No label with id or name '{label}' found in your HA instance",
)
def is_valid(self, entity: RegistryEntry) -> bool:
return any(label in entity.labels for label in self.labels) or entity.device_id in self.devices
class CategoryFilter(EntityFilter):
def __init__(self, categories: EntityCategory | str | Iterable[EntityCategory | str]) -> None:
if isinstance(categories, (EntityCategory, str)):
categories = [categories]
self.categories = []
for category in categories:
if not isinstance(category, EntityCategory):
try:
self.categories.append(EntityCategory(category))
except ValueError as err:
raise SensorConfigurationError(f"Invalid entity category: {category}") from err
else:
self.categories.append(category)
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.entity_category in self.categories
class LambdaFilter(EntityFilter):
def __init__(self, func: Callable[[RegistryEntry], bool]) -> None:
self.func = func
def is_valid(self, entity: RegistryEntry) -> bool:
return self.func(entity)
class AreaFilter(EntityFilter):
def __init__(self, hass: HomeAssistant, area_id: str | Iterable[str]) -> None:
self.area_ids: list[str] = []
self.area_devices: set[str] = set()
area_ids = [area_id] if isinstance(area_id, str) else area_id
area_reg = area_registry.async_get(hass)
device_reg = device_registry.async_get(hass)
for area_id in area_ids:
area = area_reg.async_get_area(area_id)
if area is None:
area = area_reg.async_get_area_by_name(str(area_id))
if area is None or area.id is None:
raise SensorConfigurationError(
f"No area with id or name '{area_id}' found in your HA instance",
)
self.area_ids.append(area.id)
self.area_devices.update([device.id for device in device_registry.async_entries_for_area(device_reg, area.id)])
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.area_id in self.area_ids or entity.device_id in self.area_devices
class DeviceFilter(EntityFilter):
def __init__(self, device: str | set[str]) -> None:
self.device: set[str] = {device} if isinstance(device, str) else device
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.device_id in self.device
class FloorFilter(EntityFilter):
def __init__(self, hass: HomeAssistant, floor_id: str | Iterable[str]) -> None:
self.area_ids: list[str] = []
self.devices: list[str] = []
floor_ids = [floor_id] if isinstance(floor_id, str) else floor_id
floor_reg = floor_registry.async_get(hass)
area_reg = area_registry.async_get(hass)
device_reg = device_registry.async_get(hass)
for single_floor_id in floor_ids:
floor = floor_reg.async_get_floor(single_floor_id)
if floor is None:
floor = floor_reg.async_get_floor_by_name(str(single_floor_id))
if floor is None or floor.floor_id is None:
raise SensorConfigurationError(
f"No floor with id or name '{single_floor_id}' found in your HA instance",
)
areas = area_registry.async_entries_for_floor(area_reg, floor.floor_id)
self.area_ids.extend([area.id for area in areas if area.id is not None])
for area in areas:
self.devices.extend([device.id for device in device_registry.async_entries_for_area(device_reg, area.id)])
def is_valid(self, entity: RegistryEntry) -> bool:
return entity.area_id in self.area_ids or entity.device_id in self.devices
class CompositeFilter(EntityFilter):
def __init__(
self,
filters: Sequence[EntityFilter],
operator: FilterOperator = FilterOperator.AND,
) -> None:
self.filters = filters
self.operator = operator
def is_valid(self, entity: RegistryEntry) -> bool:
evaluations = [entity_filter.is_valid(entity) for entity_filter in self.filters]
if self.operator == FilterOperator.OR:
return any(evaluations)
return all(evaluations)
class NotFilter(EntityFilter):
def __init__(self, entity_filter: EntityFilter) -> None:
self.entity_filter = entity_filter
def is_valid(self, entity: RegistryEntry) -> bool:
return not self.entity_filter.is_valid(entity)
@@ -0,0 +1,121 @@
from dataclasses import dataclass
import logging
from homeassistant.components import sensor
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from custom_components.powercalc.common import create_source_entity
from custom_components.powercalc.const import (
DATA_CONFIGURED_ENTITIES,
DATA_ENTITIES,
DOMAIN,
)
from custom_components.powercalc.discovery import get_power_profile_by_source_entity
from custom_components.powercalc.power_profile.power_profile import SUPPORTED_DOMAINS
from custom_components.powercalc.sensors.energy import RealEnergySensor
from custom_components.powercalc.sensors.power import RealPowerSensor
from custom_components.powercalc.sensors.utility_meter import VirtualUtilityMeter
from .filter import CompositeFilter, DomainFilter, EntityFilter, LambdaFilter, get_filtered_entity_list
_LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class FindEntitiesResult:
resolved: list[Entity]
discoverable: list[str]
async def find_entities(
hass: HomeAssistant,
entity_filter: EntityFilter | None = None,
include_non_powercalc: bool = True,
exclude_utility_meters: bool = True,
) -> FindEntitiesResult:
"""
Based on the given entity filter, fetch all power and energy sensors from the HA instance.
"""
domain_data = hass.data.get(DOMAIN, {})
source_entity_powercalc_entity_map: dict[str, list[tuple[Entity, bool]]] = domain_data.get(
DATA_CONFIGURED_ENTITIES,
{},
)
powercalc_entities: dict[str, Entity] = domain_data.get(
DATA_ENTITIES,
{},
)
resolved_entities: list[Entity] = []
discoverable_entities: list[str] = []
source_entities = await get_filtered_entity_list(hass, _build_filter(entity_filter))
if _LOGGER.isEnabledFor(logging.DEBUG): # pragma: no cover
_LOGGER.debug("Source entities: %s", [entity.entity_id for entity in source_entities])
for source_entity in source_entities:
entity_id = source_entity.entity_id
mapped = source_entity_powercalc_entity_map.get(entity_id)
if mapped:
resolved_entities.extend(entity for entity, _ in mapped)
continue
existing = powercalc_entities.get(entity_id)
if existing:
resolved_entities.append(existing)
continue
is_real_sensor = False
if source_entity.domain == sensor.DOMAIN:
if source_entity.platform != DOMAIN and not include_non_powercalc:
continue
device_class = source_entity.device_class or source_entity.original_device_class
if device_class == SensorDeviceClass.POWER:
resolved_entities.append(RealPowerSensor(entity_id, source_entity.unit_of_measurement))
is_real_sensor = True
elif device_class == SensorDeviceClass.ENERGY:
resolved_entities.append(RealEnergySensor(entity_id))
is_real_sensor = True
# No need to discover a profile for something we already resolved as a real sensor
if is_real_sensor:
continue
power_profile = await get_power_profile_by_source_entity(
hass,
await create_source_entity(entity_id, hass),
)
if power_profile and not await power_profile.needs_user_configuration and power_profile.is_entity_domain_supported(source_entity):
discoverable_entities.append(entity_id)
if exclude_utility_meters:
resolved_entities = [entity for entity in resolved_entities if not isinstance(entity, VirtualUtilityMeter)]
if _LOGGER.isEnabledFor(logging.DEBUG): # pragma: no cover
_LOGGER.debug("Resolved entities: %s", [entity.entity_id for entity in resolved_entities])
_LOGGER.debug("Discoverable entities: %s", discoverable_entities)
return FindEntitiesResult(resolved_entities, discoverable_entities)
def _build_filter(entity_filter: EntityFilter | None) -> EntityFilter:
base_filter = CompositeFilter(
[
DomainFilter(SUPPORTED_DOMAINS),
LambdaFilter(lambda entity: entity.platform != "utility_meter"),
LambdaFilter(lambda entity: not str(entity.unique_id).startswith("powercalc_standby_group")),
LambdaFilter(lambda entity: "tracked_" not in str(entity.unique_id)),
LambdaFilter(lambda entity: entity.platform != "tasmota" or not str(entity.entity_id).endswith(("_yesterday", "_today"))),
],
)
if not entity_filter:
return base_filter
return CompositeFilter([base_filter, entity_filter])
+310
View File
@@ -0,0 +1,310 @@
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
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
from custom_components.powercalc.const import (
DUMMY_ENTITY_ID,
PLACEHOLDER_ENTITY_BY_DEVICE_CLASS,
PLACEHOLDER_ENTITY_BY_TRANSLATION_KEY,
CalculationStrategy,
)
from custom_components.powercalc.power_profile.power_profile import PowerProfile
_LOGGER = logging.getLogger(__name__)
PLACEHOLDER_REGEX = re.compile(r"\[\[\s*([A-Za-z_]\w*(?::[A-Za-z_]\w*)*)\s*\]\]")
async 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")
return f"{base_path}/{sub_path}"
def get_library_json_path() -> str:
"""Get the path to the library.json file."""
return get_library_path("library.json")
def get_or_create_unique_id(
sensor_config: ConfigType,
source_entity: SourceEntity,
power_profile: PowerProfile | None,
) -> str:
"""Get or create the unique id."""
unique_id = sensor_config.get(CONF_UNIQUE_ID)
if unique_id:
return str(unique_id)
# For multi-switch and wled strategy we need to use the device id as unique id
# As we don't want to start a discovery for each switch entity
if (
source_entity.device_entry
and power_profile
and power_profile.calculation_strategy in [CalculationStrategy.WLED, CalculationStrategy.MULTI_SWITCH]
):
return f"pc_{source_entity.device_entry.id}"
if source_entity and source_entity.entity_id != DUMMY_ENTITY_ID:
source_unique_id = source_entity.unique_id or source_entity.entity_id
# Prefix with pc_ to avoid conflicts with other integrations
return f"pc_{source_unique_id}"
return str(uuid.uuid4())
P = TypeVar("P") # Used for positional and keyword argument types
R = TypeVar("R") # Used for return type
class RelatedEntityPlaceholderDefinition(NamedTuple):
prefix: str
lookup_label: str
resolver: Callable[[HomeAssistant, SourceEntity, str], str | None]
def make_hashable(arg: Any) -> Any: # noqa: ANN401
"""Convert unhashable arguments to hashable equivalents."""
if isinstance(arg, set):
return frozenset(arg)
if isinstance(arg, list):
return tuple(arg)
if isinstance(arg, dict):
return frozenset((key, make_hashable(value)) for key, value in arg.items())
return arg
def async_cache[R](func: Callable[..., Coroutine[Any, Any, R]]) -> Callable[..., Coroutine[Any, Any, R]]:
"""
A decorator to cache results of an async function based on its arguments.
Args:
func: The asynchronous function to decorate.
Returns:
A decorated asynchronous function with caching.
"""
cache: dict[tuple[tuple[Any, ...], frozenset], R] = {}
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> R: # noqa: ANN401
# Make arguments hashable
hashable_args = tuple(make_hashable(arg) for arg in args)
hashable_kwargs = frozenset((key, make_hashable(value)) for key, value in kwargs.items())
cache_key = (hashable_args, hashable_kwargs)
if cache_key in cache:
return cache[cache_key]
result = await func(*args, **kwargs)
cache[cache_key] = result
return result
return wrapper
def collect_placeholders(data: list | str | dict[str, Any]) -> set[str]:
found: set[str] = set()
if isinstance(data, dict):
for v in data.values():
found |= collect_placeholders(v)
elif isinstance(data, list):
for v in data:
found |= collect_placeholders(v)
elif isinstance(data, str):
found |= set(PLACEHOLDER_REGEX.findall(data))
return found
def replace_placeholders(data: list | str | dict[str, Any], replacements: dict[str, str]) -> list | str | dict[str, Any]:
"""Replace placeholders in a dictionary with values from a replacement dictionary."""
if isinstance(data, dict):
for key, value in data.items():
data[key] = replace_placeholders(value, replacements)
elif isinstance(data, list):
for i in range(len(data)):
data[i] = replace_placeholders(data[i], replacements)
elif isinstance(data, str):
# Use the same regex pattern as PLACEHOLDER_REGEX
matches = PLACEHOLDER_REGEX.findall(data)
for match in matches:
if match in replacements:
# Replace [[variable]] with its value
data = data.replace(f"[[{match}]]", str(replacements[match]))
return data
def iter_related_entity_placeholders(placeholders: Iterable[str]) -> Iterator[str]:
"""Yield placeholders that need lookup against entities on the same device."""
for placeholder in placeholders:
if parse_related_entity_placeholder(placeholder):
yield placeholder
def resolve_related_entity_placeholder(
hass: HomeAssistant,
placeholder: str,
source_entity: SourceEntity | None = None,
) -> str | None:
"""Resolve a single related-entity placeholder against the entity registry."""
if not source_entity:
return None
parsed_placeholder = parse_related_entity_placeholder(placeholder)
if not parsed_placeholder:
return None
definition, lookup_value = parsed_placeholder
return definition.resolver(hass, source_entity, lookup_value)
def build_related_entity_placeholder_not_found_message(placeholder: str, source_entity_id: str) -> str:
parsed_placeholder = parse_related_entity_placeholder(placeholder)
if not parsed_placeholder:
return f"Could not find related entity for placeholder {placeholder} of entity {source_entity_id}"
definition, lookup_value = parsed_placeholder
return f"Could not find related entity for {definition.lookup_label} {lookup_value} of entity {source_entity_id}"
def parse_related_entity_placeholder(placeholder: str) -> tuple[RelatedEntityPlaceholderDefinition, str] | None:
for definition in RELATED_ENTITY_PLACEHOLDER_DEFINITIONS:
if placeholder.startswith(definition.prefix):
return definition, placeholder.removeprefix(definition.prefix)
return None
def _resolve_related_entity_by_device_class(
hass: HomeAssistant,
source_entity: SourceEntity,
raw_device_class: str,
) -> str | None:
device_class = _parse_related_entity_device_class(raw_device_class)
if device_class is None:
return None
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,
"device class",
_resolve_related_entity_by_device_class,
),
RelatedEntityPlaceholderDefinition(
PLACEHOLDER_ENTITY_BY_TRANSLATION_KEY,
"translation key",
_resolve_related_entity_by_translation_key,
),
)
def _parse_related_entity_device_class(raw_device_class: str) -> SensorDeviceClass | BinarySensorDeviceClass | None:
try:
return SensorDeviceClass(raw_device_class)
except ValueError:
try:
return BinarySensorDeviceClass(raw_device_class)
except ValueError:
return None
def get_related_entity_by_device_class(
hass: HomeAssistant,
source_entity: SourceEntity,
device_class: SensorDeviceClass | BinarySensorDeviceClass,
) -> str | None:
"""Get related entity from same device by device class."""
return _get_related_entity_for_device(
hass,
source_entity=source_entity,
match_label="device class",
match_value=device_class,
matcher=lambda entity_entry: (entity_entry.device_class or entity_entry.original_device_class) == device_class,
)
def get_related_entity_by_translation_key(
hass: HomeAssistant,
source_entity: SourceEntity,
translation_key: str,
) -> str | None:
"""Get related entity from same device by translation key."""
return _get_related_entity_for_device(
hass,
source_entity=source_entity,
match_label="translation key",
match_value=translation_key,
matcher=lambda entity_entry: entity_entry.translation_key == translation_key,
)
def _get_related_entity_for_device(
hass: HomeAssistant,
source_entity: SourceEntity,
match_label: str,
match_value: SensorDeviceClass | BinarySensorDeviceClass | str,
matcher: Callable[[RegistryEntry], bool],
) -> str | None:
"""Get the first related entity on the same device matching the given predicate."""
entity_reg = entity_registry.async_get(hass)
if not source_entity.device_entry:
_LOGGER.debug("No device_id available, cannot find related entity")
return None
related_entities = [
entity_entry.entity_id
for entity_entry in entity_registry.async_entries_for_device(entity_reg, source_entity.device_entry.id)
if matcher(entity_entry)
]
if not related_entities:
_LOGGER.debug("No related entities found for device %s with %s %s", source_entity.device_entry.id, match_label, match_value)
return None
return related_entities[0]
+40
View File
@@ -0,0 +1,40 @@
{
"services": {
"activate_playbook": {
"service": "mdi:play"
},
"calibrate_utility_meter": {
"service": "mdi:wrench"
},
"calibrate_energy": {
"service": "mdi:wrench"
},
"change_gui_config": {
"service": "mdi:cogs"
},
"get_active_playbook": {
"service": "mdi:book-open-variant-outline"
},
"get_group_entities": {
"service": "mdi:format-list-group"
},
"increase_daily_energy": {
"service": "mdi:numeric"
},
"reload": {
"service": "mdi:reload"
},
"reset_energy": {
"service": "mdi:restore"
},
"stop_playbook": {
"service": "mdi:stop"
},
"switch_sub_profile": {
"service": "mdi:toggle-switch"
},
"update_library": {
"service": "mdi:cloud-download"
}
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"after_dependencies": [
"integration",
"utility_meter"
],
"codeowners": [
"@bramstroker"
],
"config_flow": true,
"dependencies": [
"light",
"group",
"template",
"select",
"utility_meter"
],
"documentation": "https://docs.powercalc.nl",
"domain": "powercalc",
"iot_class": "calculated",
"issue_tracker": "https://github.com/bramstroker/homeassistant-powercalc/issues",
"name": "Powercalc",
"requirements": [
"numpy>=1.21.1"
],
"version": "v1.20.14"
}
+185
View File
@@ -0,0 +1,185 @@
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENABLED, CONF_ID, CONF_PATH
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.issue_registry import async_create_issue
from custom_components.powercalc.const import (
CONF_CREATE_ENERGY_SENSOR,
CONF_DISCOVERY,
CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED,
CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED,
CONF_ENABLE_AUTODISCOVERY_DEPRECATED,
CONF_ENERGY_UPDATE_INTERVAL,
CONF_EXCLUDE_DEVICE_TYPES,
CONF_EXCLUDE_SELF_USAGE,
CONF_FIXED,
CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED,
CONF_GROUP_ENERGY_UPDATE_INTERVAL,
CONF_GROUP_UPDATE_INTERVAL_DEPRECATED,
CONF_MANUFACTURER,
CONF_MODEL,
CONF_PLAYBOOK,
CONF_PLAYBOOKS,
CONF_POWER,
CONF_POWER_TEMPLATE,
CONF_SENSOR_TYPE,
CONF_STATE,
CONF_STATE_TRIGGER,
CONF_STATES_POWER,
CONF_STATES_TRIGGER,
DOMAIN,
ENTRY_GLOBAL_CONFIG_UNIQUE_ID,
)
from custom_components.powercalc.power_profile.library import ModelInfo, ProfileLibrary
async def async_migrate_config_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
version = config_entry.version
data = {**config_entry.data}
if version <= 1:
conf_fixed = data.get(CONF_FIXED, {})
if CONF_POWER in conf_fixed and CONF_POWER_TEMPLATE in conf_fixed:
conf_fixed.pop(CONF_POWER, None)
if version <= 2 and data.get(CONF_SENSOR_TYPE) and CONF_CREATE_ENERGY_SENSOR not in data:
data[CONF_CREATE_ENERGY_SENSOR] = True
if version <= 3:
conf_playbook = data.get(CONF_PLAYBOOK, {})
if CONF_STATES_TRIGGER in conf_playbook:
data[CONF_PLAYBOOK][CONF_STATE_TRIGGER] = conf_playbook.pop(CONF_STATES_TRIGGER)
if version <= 4 and config_entry.entry_id == ENTRY_GLOBAL_CONFIG_UNIQUE_ID:
discovery_config = {
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),
}
data[CONF_DISCOVERY] = discovery_config
for key in [
CONF_ENABLE_AUTODISCOVERY_DEPRECATED,
CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED,
CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED,
]:
data.pop(key, None)
if version <= 5:
conf_playbook = data.get(CONF_PLAYBOOK, {})
if CONF_PLAYBOOKS in conf_playbook:
data[CONF_PLAYBOOK][CONF_PLAYBOOKS] = [{CONF_ID: key, CONF_PATH: val} for key, val in conf_playbook.pop(CONF_PLAYBOOKS).items()]
if version <= 6:
conf_fixed = data.get(CONF_FIXED, {})
if CONF_STATES_POWER in conf_fixed and isinstance(conf_fixed[CONF_STATES_POWER], dict):
data[CONF_FIXED][CONF_STATES_POWER] = [{CONF_STATE: key, CONF_POWER: val} for key, val in conf_fixed[CONF_STATES_POWER].items()]
hass.config_entries.async_update_entry(config_entry, data=data, version=7)
async def async_fix_legacy_profile_config_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Always normalize legacy profile references on setup, based on library metadata."""
manufacturer = config_entry.data.get(CONF_MANUFACTURER)
model = config_entry.data.get(CONF_MODEL)
if not manufacturer or not model:
return
model_id = str(model)
sub_profile = ""
if "/" in model_id:
model_id, sub_profile = model_id.split("/", 1)
library = await ProfileLibrary.factory(hass)
migrated_profile = await library.find_model_migration(ModelInfo(str(manufacturer), model_id))
if migrated_profile is None:
return
resolved_manufacturer = migrated_profile.manufacturer
migrated_model = migrated_profile.model
if migrated_model == model_id and str(manufacturer).lower() == resolved_manufacturer.lower():
return
updated_model = f"{migrated_model}/{sub_profile}" if sub_profile else migrated_model
hass.config_entries.async_update_entry(
config_entry,
data={
**config_entry.data,
CONF_MANUFACTURER: resolved_manufacturer,
CONF_MODEL: updated_model,
},
)
async def handle_legacy_discovery_config(hass: HomeAssistant, global_config: dict, yaml_config: dict) -> None:
"""Handle legacy discovery config. Might be removed in future Powercalc version"""
discovery_options = global_config.setdefault(CONF_DISCOVERY, {})
deprecated_map = {
CONF_EXCLUDE_DEVICE_TYPES: CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED,
CONF_EXCLUDE_SELF_USAGE: CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED,
CONF_ENABLED: CONF_ENABLE_AUTODISCOVERY_DEPRECATED,
}
legacy_discovery_config = False
for new_key, old_key in deprecated_map.items():
if old_key not in yaml_config:
continue
discovery_options[new_key] = yaml_config[old_key] # pragma: nocover
global_config.pop(old_key, None)
legacy_discovery_config = True
if not legacy_discovery_config:
return
async_create_issue(
hass=hass,
domain=DOMAIN,
issue_id="legacy_discovery_config",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="legacy_config",
translation_placeholders={
"type": "discovery",
},
learn_more_url="https://docs.powercalc.nl/configuration/migration/discovery-config",
breaks_in_ha_version="2026.06",
)
async def handle_legacy_update_interval_config(hass: HomeAssistant, global_config: dict, yaml_config: dict) -> None:
"""Handle legacy group update interval config. Might be removed in future Powercalc version"""
has_legacy_config = False
if CONF_GROUP_UPDATE_INTERVAL_DEPRECATED in yaml_config:
global_config[CONF_GROUP_ENERGY_UPDATE_INTERVAL] = yaml_config[CONF_GROUP_UPDATE_INTERVAL_DEPRECATED]
global_config.pop(CONF_GROUP_UPDATE_INTERVAL_DEPRECATED, None)
has_legacy_config = True
if CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED in yaml_config:
legacy_config = yaml_config[CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED]
if isinstance(legacy_config, timedelta):
legacy_config = legacy_config.seconds
global_config[CONF_ENERGY_UPDATE_INTERVAL] = legacy_config
global_config.pop(CONF_FORCE_UPDATE_FREQUENCY_DEPRECATED, None)
has_legacy_config = True
if not has_legacy_config:
return
async_create_issue(
hass=hass,
domain=DOMAIN,
issue_id="legacy_update_interval_config",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="legacy_config",
translation_placeholders={
"type": "group_update_interval",
},
learn_more_url="https://docs.powercalc.nl/configuration/migration/update-interval-config",
breaks_in_ha_version="2026.06",
)
@@ -0,0 +1,13 @@
from homeassistant.exceptions import HomeAssistantError
class LibraryError(HomeAssistantError):
"""Raised when an error occurred in the library logic."""
class LibraryLoadingError(LibraryError):
"""Raised when an error occurred during library loading."""
class ProfileDownloadError(LibraryError):
"""Raised when an error occurred during profile download."""
@@ -0,0 +1,72 @@
from __future__ import annotations
import logging
import os
from homeassistant.core import HomeAssistant
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.const import (
CONF_CUSTOM_MODEL_DIRECTORY,
CONF_MANUFACTURER,
CONF_MODEL,
CONF_VARIABLES,
MANUFACTURER_WLED,
)
from custom_components.powercalc.errors import ModelNotSupportedError
from .error import LibraryError
from .library import ModelInfo, ProfileLibrary
from .power_profile import PowerProfile
_LOGGER = logging.getLogger(__name__)
async def get_power_profile(
hass: HomeAssistant,
config: dict,
source_entity: SourceEntity | None = None,
model_info: ModelInfo | None = None,
log_errors: bool = True,
process_variables: bool = True,
) -> PowerProfile | None:
manufacturer = config.get(CONF_MANUFACTURER)
model = config.get(CONF_MODEL)
model_id = None
if (manufacturer is None or model is None) and model_info:
manufacturer = config.get(CONF_MANUFACTURER) or model_info.manufacturer
model = config.get(CONF_MODEL) or model_info.model
model_id = model_info.model_id
custom_model_directory = config.get(CONF_CUSTOM_MODEL_DIRECTORY)
if (not manufacturer or not model) and not custom_model_directory:
return None
if manufacturer == MANUFACTURER_WLED:
return None
if custom_model_directory:
custom_model_directory = os.path.join(
hass.config.config_dir,
custom_model_directory,
)
library = await ProfileLibrary.factory(hass)
try:
variables = config.get(CONF_VARIABLES, {}).copy()
profile = await library.get_profile(
ModelInfo(manufacturer or "", model or "", model_id),
source_entity,
custom_model_directory,
variables,
process_variables,
)
except LibraryError as err:
if log_errors:
_LOGGER.error("Problem loading model: %s", err)
raise ModelNotSupportedError(
f"Model not found in library (manufacturer: {manufacturer}, model: {model})",
) from err
return profile
@@ -0,0 +1,327 @@
from __future__ import annotations
import json
import os
import re
from typing import Any, NamedTuple, cast
from homeassistant.core import HomeAssistant
from homeassistant.helpers.singleton import singleton
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.const import CONF_DISABLE_LIBRARY_DOWNLOAD, DOMAIN, DOMAIN_CONFIG
from custom_components.powercalc.helpers import (
build_related_entity_placeholder_not_found_message,
collect_placeholders,
iter_related_entity_placeholders,
replace_placeholders,
resolve_related_entity_placeholder,
)
from .error import LibraryError
from .loader.composite import CompositeLoader
from .loader.local import LocalLoader
from .loader.protocol import Loader
from .loader.remote import RemoteLoader
from .power_profile import DeviceType, DiscoveryBy, PowerProfile
LEGACY_CUSTOM_DATA_DIRECTORY = "powercalc-custom-models"
CUSTOM_DATA_DIRECTORY = "powercalc/profiles"
def load_sub_profile_data(base_dir: str) -> list[tuple[str, dict[str, Any]]]:
"""Load sub-profile JSON blobs from disk."""
sub_dirs = next(os.walk(base_dir))[1]
result = []
for sub_dir in sub_dirs:
json_path = os.path.join(base_dir, sub_dir, "model.json")
if os.path.isfile(json_path):
with open(json_path, encoding="utf-8") as f:
json_data = cast(dict[str, Any], json.load(f))
else:
json_data = {}
result.append((sub_dir, json_data))
return sorted(result, key=lambda item: item[0])
class ProfileLibrary:
def __init__(self, hass: HomeAssistant, loader: Loader) -> None:
self._hass = hass
self._loader = loader
self._profiles: dict[str, list[PowerProfile]] = {}
self._manufacturer_models: dict[str, set[tuple[str, str]]] = {}
self._manufacturer_device_types: dict[str, list] = {}
async def initialize(self) -> None:
await self._loader.initialize()
@staticmethod
@singleton("powercalc_library")
async def factory(hass: HomeAssistant) -> ProfileLibrary:
"""
Creates and loads the profile library.
Make sure we have a single instance throughout the application.
"""
library = ProfileLibrary(hass, ProfileLibrary.create_loader(hass))
await library.initialize()
return library
@staticmethod
def create_loader(hass: HomeAssistant, skip_remote_loader: bool = False) -> Loader:
loaders: list[Loader] = [
LocalLoader(hass, data_dir)
for data_dir in [
os.path.join(hass.config.config_dir, LEGACY_CUSTOM_DATA_DIRECTORY),
os.path.join(hass.config.config_dir, CUSTOM_DATA_DIRECTORY),
os.path.join(os.path.dirname(__file__), "../custom_data"),
]
if os.path.exists(data_dir)
]
domain_config = hass.data.get(DOMAIN, {})
global_config = domain_config.get(DOMAIN_CONFIG, {})
disable_library_download: bool = bool(global_config.get(CONF_DISABLE_LIBRARY_DOWNLOAD, False))
if not disable_library_download and not skip_remote_loader:
loaders.append(RemoteLoader(hass))
return CompositeLoader(loaders)
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None = None,
discovery_by: DiscoveryBy | None = None,
) -> list[tuple[str, str]]:
"""Get listing of available manufacturers."""
manufacturers = await self._loader.get_manufacturer_listing(device_types, discovery_by)
return sorted(manufacturers)
async def get_model_listing(
self,
manufacturer: str,
device_types: set[DeviceType] | None = None,
discovery_by: DiscoveryBy | None = None,
) -> list[tuple[str, str]]:
"""Get listing of available models and display names for a given manufacturer."""
resolved_manufacturers = await self._loader.find_manufacturers(manufacturer)
if not resolved_manufacturers:
return []
all_models: list[tuple[str, str]] = []
for manufacturer in resolved_manufacturers:
cache_key = f"{manufacturer}/{device_types}/{discovery_by}"
cached_models = self._manufacturer_models.get(cache_key)
if cached_models:
all_models.extend(sorted(cached_models))
continue
models = await self._loader.get_model_listing(manufacturer, device_types, discovery_by)
self._manufacturer_models[cache_key] = models
all_models.extend(sorted(models))
return sorted(all_models, key=lambda model: model[0])
async def get_profile(
self,
model_info: ModelInfo,
source_entity: SourceEntity | None = None,
custom_directory: str | None = None,
variables: dict[str, str] | None = None,
process_variables: bool = True,
) -> PowerProfile:
"""Get a power profile for a given manufacturer and model."""
# Support multiple LUT in subdirectories
sub_profile = None
if "/" in model_info.model:
(model, sub_profile) = model_info.model.split("/", 1)
model_info = ModelInfo(model_info.manufacturer, model, model_info.model_id)
if not custom_directory:
models = await self.find_models(model_info)
if not models:
raise LibraryError(f"Model {model_info.manufacturer} {model_info.model} not found")
model_info = next(iter(models))
profile = await self.create_power_profile(model_info, source_entity, custom_directory, variables, process_variables)
if sub_profile:
await profile.select_sub_profile(sub_profile)
return profile
async def create_power_profile(
self,
model_info: ModelInfo,
source_entity: SourceEntity | None = None,
custom_directory: str | None = None,
variables: dict[str, str] | None = None,
process_variables: bool = True,
) -> PowerProfile:
"""Create a power profile object from the model JSON data."""
json_data, directory = await self._load_model_data(model_info.manufacturer, model_info.model, custom_directory)
json_data = self._process_profile_json(json_data, variables or {}, source_entity, process_variables)
if linked_profile := json_data.get("linked_profile", json_data.get("linked_lut")):
linked_manufacturer, linked_model = linked_profile.split("/")
linked_json_data, directory = await self._load_model_data(linked_manufacturer, linked_model, custom_directory)
json_data.update(linked_json_data)
raw_sub_profiles = await self._hass.async_add_executor_job(load_sub_profile_data, directory)
sub_profiles = [
(
sub_dir,
self._process_profile_json(sub_profile_json, variables or {}, source_entity, process_variables),
)
for sub_dir, sub_profile_json in raw_sub_profiles
]
return await self._create_power_profile_instance(
model_info.manufacturer,
model_info.model,
directory,
json_data,
sub_profiles,
)
def _process_profile_json(
self,
json_data: dict[str, Any],
variables: dict[str, str],
source_entity: SourceEntity | None,
process_variables: bool,
) -> dict[str, Any]:
# json_data is potentially retrieved from cache, so we need to copy it to avoid modifying the cache
json_data = json_data.copy()
if not process_variables:
return json_data
if json_data.get("fields"): # When custom fields in profile are defined, make sure all variables are passed
self.validate_variables(json_data, variables)
placeholders = collect_placeholders(json_data)
replacements = self.compute_replacement_variables(placeholders, variables.copy(), source_entity)
return cast(dict[str, Any], replace_placeholders(json_data, replacements))
def compute_replacement_variables(self, placeholders: set[str], variables: dict[str, str], source_entity: SourceEntity | None) -> dict[str, str]:
variables = variables or {}
if source_entity:
if "entity" in placeholders:
variables["entity"] = source_entity.entity_id
for placeholder in iter_related_entity_placeholders(placeholders):
related_entity = resolve_related_entity_placeholder(
self._hass,
placeholder,
source_entity=source_entity,
)
if not related_entity:
raise LibraryError(build_related_entity_placeholder_not_found_message(placeholder, source_entity.entity_id))
variables[placeholder] = related_entity
return variables
@staticmethod
def validate_variables(json_data: dict[str, Any], variables: dict[str, str]) -> None:
fields = json_data.get("fields", {}).keys()
# Check if all variables are valid for the model
for variable in variables:
if variable not in fields and variable != "entity":
raise LibraryError(f"Variable {variable} is not valid for this model")
# Check if all fields have corresponding variables
missing_fields = [field for field in fields if field not in variables]
if missing_fields:
raise LibraryError(f"Missing variables for fields: {', '.join(missing_fields)}")
async def find_manufacturers(self, manufacturer: str) -> set[str]:
"""Resolve the manufacturer, either from the model info or by loading it."""
return await self._loader.find_manufacturers(manufacturer)
async def find_models(self, model_info: ModelInfo) -> list[ModelInfo]:
"""Resolve the model identifier, searching for it if no custom directory is provided."""
search: set[str] = set()
for model_identifier in (model_info.model_id, model_info.model):
if model_identifier:
model_identifier = model_identifier.replace("#slash#", "/")
search.update(
{
model_identifier,
model_identifier.lower(),
re.sub(r"^(.*)\(([^()]+)\)$", r"\2", model_identifier),
},
)
if "/" in model_identifier:
search.update(model_identifier.split("/"))
manufacturers = await self._loader.find_manufacturers(model_info.manufacturer)
if not manufacturers:
return []
found_models: list[ModelInfo] = []
for manufacturer in manufacturers:
models = await self._loader.find_model(manufacturer, search)
if models:
found_models.extend(ModelInfo(manufacturer, model) for model in models)
return list(dict.fromkeys(found_models))
async def find_model_migration(self, model_info: ModelInfo) -> ModelInfo | None:
"""Resolve a legacy canonical model id to its replacement using library metadata."""
manufacturers = await self._loader.find_manufacturers(model_info.manufacturer)
if not manufacturers:
return None
matches: set[ModelInfo] = set()
for manufacturer in manufacturers:
migrated_model = await self._loader.find_model_migration(manufacturer, model_info.model)
if migrated_model:
matches.add(ModelInfo(manufacturer, migrated_model))
if len(matches) != 1:
return None
return next(iter(matches))
async def _load_model_data(self, manufacturer: str, model: str, custom_directory: str | None) -> tuple[dict, str]:
"""Load the model data from the appropriate directory."""
loader = LocalLoader(self._hass, custom_directory, is_custom_directory=True) if custom_directory else self._loader
result = await loader.load_model(manufacturer, model)
if not result:
raise LibraryError(f"Model {manufacturer} {model} not found")
return result
async def _create_power_profile_instance(
self,
manufacturer: str,
model: str,
directory: str,
json_data: dict,
sub_profiles: list[tuple[str, dict]] | None = None,
) -> PowerProfile:
"""Create and initialize the PowerProfile object."""
profile = PowerProfile(
self._hass,
manufacturer=manufacturer,
model=model,
directory=directory,
json_data=json_data,
sub_profiles=sub_profiles,
)
if not profile.sub_profile and profile.sub_profile_select:
await profile.select_sub_profile(profile.sub_profile_select.default)
return profile
def get_loader(self) -> Loader:
return self._loader
class ModelInfo(NamedTuple):
manufacturer: str
model: str
# Starting from HA 2024.8 we can use model_id to identify the model
model_id: str | None = None
@@ -0,0 +1,75 @@
import logging
from custom_components.powercalc.power_profile.loader.protocol import Loader
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy
_LOGGER = logging.getLogger(__name__)
class CompositeLoader(Loader):
def __init__(self, loaders: list[Loader]) -> None:
self.loaders = loaders
async def initialize(self) -> None:
[await loader.initialize() for loader in self.loaders] # type: ignore[func-returns-value]
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of available manufacturers."""
return {manufacturer for loader in self.loaders for manufacturer in await loader.get_manufacturer_listing(device_types, discovery_by)}
async def find_manufacturers(self, search: str) -> set[str]:
"""Check if a manufacturer is available. Also must check aliases."""
search = search.lower()
found_manufacturers = set()
for loader in self.loaders:
manufacturers = await loader.find_manufacturers(search)
if manufacturers:
found_manufacturers.update(manufacturers)
return found_manufacturers
async def get_model_listing(
self,
manufacturer: str,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of available models and display names for a given manufacturer."""
return {model for loader in self.loaders for model in await loader.get_model_listing(manufacturer, device_types, discovery_by)}
async def load_model(self, manufacturer: str, model: str) -> tuple[dict, str] | None:
for loader in self.loaders:
result = await loader.load_model(manufacturer, model)
if result:
return result
return None
async def find_model(self, manufacturer: str, search: set[str]) -> list[str]:
"""Find the model in the library."""
models = []
for loader in self.loaders:
models.extend(await loader.find_model(manufacturer, search))
return models
async def find_model_migration(self, manufacturer: str, model: str) -> str | None:
"""Find the canonical model id for a legacy profile id."""
matches: set[str] = set()
for loader in self.loaders:
migrated_model = await loader.find_model_migration(manufacturer, model)
if migrated_model:
matches.add(migrated_model)
if len(matches) != 1:
return None
return next(iter(matches))
@@ -0,0 +1,211 @@
import json
import logging
import os
import re
from typing import Any, cast
from homeassistant.core import HomeAssistant
from custom_components.powercalc.power_profile.error import LibraryLoadingError
from custom_components.powercalc.power_profile.loader.protocol import Loader
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy, PowerProfile
_LOGGER = logging.getLogger(__name__)
class LocalLoader(Loader):
def __init__(self, hass: HomeAssistant, directory: str, is_custom_directory: bool = False) -> None:
self._is_custom_directory = is_custom_directory
self._data_directory = directory
self._hass = hass
self._manufacturer_model_listing: dict[str, dict[str, PowerProfile]] = {}
async def initialize(self) -> None:
"""Initialize the loader."""
if not self._is_custom_directory:
await self._hass.async_add_executor_job(self._load_custom_library)
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of all available manufacturers or filtered by model device_type."""
if device_types is None:
if discovery_by is None:
return {(manufacturer, manufacturer) for manufacturer in self._manufacturer_model_listing}
return {
(manufacturer, manufacturer)
for manufacturer, profiles in self._manufacturer_model_listing.items()
if any(profile.discovery_by == discovery_by for profile in profiles.values())
}
manufacturers: set[tuple[str, str]] = set()
for manufacturer in self._manufacturer_model_listing:
models = await self.get_model_listing(manufacturer, device_types, discovery_by)
if not models:
continue
manufacturers.add((manufacturer, manufacturer))
return manufacturers
async def find_manufacturers(self, search: str) -> set[str]:
"""Check if a manufacturer is available."""
_search = search.lower()
manufacturer_list = self._manufacturer_model_listing.keys()
if _search in manufacturer_list:
return {_search}
return set()
async def get_model_listing(
self,
manufacturer: str,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of available models for a given manufacturer.
param manufacturer: manufacturer always handled in lower case
param device_type: models of the manufacturer will be filtered by DeviceType, models
without assigned device_type will be handled as DeviceType.LIGHT.
None will return all models of a manufacturer.
returns: Set[tuple[str, str]] of (model_id, model_name)
"""
found_models: set[tuple[str, str]] = set()
models = self._manufacturer_model_listing.get(manufacturer.lower())
if not models:
return found_models
for profile in models.values():
if device_types and profile.device_type not in device_types:
continue
if discovery_by and profile.discovery_by != discovery_by:
continue
found_models.add((profile.model, profile.name or profile.model))
return found_models
async def load_model(self, manufacturer: str, model: str) -> tuple[dict, str] | None:
"""Load a model.json file from disk for a given manufacturer.lower() and model.lower()
by querying the custom library.
If self._is_custom_directory == true model.json will be loaded directly from there.
returns: tuple[dict, str] model.json as dictionary and model as lower case
returns: None when manufacturer, model or model path not found
raises LibraryLoadingError: model.json not found
"""
_manufacturer = manufacturer.lower()
_model = model.lower()
if self._is_custom_directory:
model_path = os.path.join(self._data_directory)
model_json_path = os.path.join(model_path, "model.json")
if not os.path.exists(model_json_path):
raise LibraryLoadingError(f"model.json not found for manufacturer {_manufacturer} " + f"and model {_model} in path {model_json_path}")
model_json = await self._hass.async_add_executor_job(self._load_json, model_json_path)
return model_json, model_path
lib_models = self._manufacturer_model_listing.get(_manufacturer)
if lib_models is None:
return None
lib_model = lib_models.get(_model)
if lib_model is None:
return None
model_path = lib_model.get_model_directory()
model_json = lib_model.json_data
return model_json, model_path
async def find_model(self, manufacturer: str, search: set[str]) -> list[str]:
"""Find a model for a given manufacturer. Also must check aliases."""
_manufacturer = manufacturer.lower()
models = self._manufacturer_model_listing.get(_manufacturer)
if not models:
return []
search_lower = {phrase.lower() for phrase in search}
profile = next((models[model] for model in models if model.lower() in search_lower), None)
return [profile.model] if profile else []
async def find_model_migration(self, manufacturer: str, model: str) -> str | None:
"""Local custom libraries do not support metadata-driven legacy profile migrations."""
return None
def _load_custom_library(self) -> None:
"""Loading custom models and aliases from file system.
Manufacturer directories without model directories and model.json files within
are not loaded. Same is with model directories without model.json files.
"""
base_path = self._data_directory
if not os.path.exists(base_path):
_LOGGER.error("Custom library directory does not exist: %s", base_path)
return
self._manufacturer_model_listing.clear()
for manufacturer_dir in next(os.walk(base_path))[1]:
manufacturer_path = os.path.join(base_path, manufacturer_dir)
manufacturer = manufacturer_dir.lower()
for model_dir in next(os.walk(manufacturer_path))[1]:
pattern = re.compile(r"^\..*")
if pattern.match(model_dir):
continue
model_path = os.path.join(manufacturer_path, model_dir)
model_json_path = os.path.join(model_path, "model.json")
if not os.path.exists(model_json_path):
_LOGGER.warning("model.json should exist in %s!", model_path)
continue
model_json = self._load_json(model_json_path)
profile = PowerProfile(
self._hass,
manufacturer=manufacturer,
model=model_dir,
directory=model_path,
json_data=model_json,
)
self._add_profile_to_library(profile)
for alias in profile.aliases:
self._add_profile_to_library(
PowerProfile(
self._hass,
manufacturer=manufacturer,
model=alias,
directory=model_path,
json_data=model_json,
),
)
def _add_profile_to_library(self, profile: PowerProfile) -> None:
"""Add profile to the library lookup dictionary."""
manufacturer = profile.manufacturer
if self._manufacturer_model_listing.get(manufacturer) is None:
self._manufacturer_model_listing[manufacturer] = {}
search_key = profile.model.lower()
if self._manufacturer_model_listing[manufacturer].get(search_key):
_LOGGER.error(
"Double entry manufacturer/model in custom library: %s/%s",
profile.manufacturer,
profile.model,
)
return
self._manufacturer_model_listing[manufacturer].update({search_key: profile})
def _load_json(self, model_json_path: str) -> dict[str, Any]:
"""Load model.json file for a given model."""
with open(model_json_path) as file:
return cast(dict[str, Any], json.load(file))
@@ -0,0 +1,35 @@
from typing import Protocol
from custom_components.powercalc.power_profile.power_profile import DeviceType, DiscoveryBy
class Loader(Protocol):
async def initialize(self) -> None:
"""Initialize the loader."""
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of possible manufacturers."""
async def find_manufacturers(self, search: str) -> set[str]:
"""Check if a manufacturer is available. Also must check aliases."""
async def get_model_listing(
self,
manufacturer: str,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of available models and display names for a given manufacturer."""
async def load_model(self, manufacturer: str, model: str) -> tuple[dict, str] | None:
"""Load and optionally download a model profile."""
async def find_model(self, manufacturer: str, search: set[str]) -> list[str]:
"""Check if a model is available. Also must check aliases."""
async def find_model_migration(self, manufacturer: str, model: str) -> str | None:
"""Return the canonical model id for a legacy profile id using library metadata."""
@@ -0,0 +1,406 @@
import asyncio
from collections.abc import Callable, Coroutine
from functools import partial
import json
from json import JSONDecodeError
import logging
import os
import shutil
from typing import Any, NotRequired, TypedDict, cast
import aiohttp
from aiohttp import ClientError
from awesomeversion import AwesomeVersion
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
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.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
_LOGGER = logging.getLogger(__name__)
ENDPOINT_LIBRARY = f"{API_URL}/library"
ENDPOINT_DOWNLOAD = f"{API_URL}/download"
TIMEOUT_SECONDS = 30
class LibraryModel(TypedDict):
id: str
name: NotRequired[str]
aliases: NotRequired[list[str]]
legacy_ids: NotRequired[list[str]]
hash: str
device_type: NotRequired[DeviceType]
discovery_by: NotRequired[DiscoveryBy]
min_version: NotRequired[str]
class LibraryManufacturer(TypedDict):
name: str
dir_name: str
aliases: NotRequired[list[str]]
models: list[LibraryModel]
class RemoteLoader(Loader):
retry_timeout = 3
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self.library_contents: dict = {}
self.model_infos: dict[str, LibraryModel] = {}
self.manufacturer_models: dict[str, list[LibraryModel]] = {}
self.model_lookup: dict[str, dict[str, list[LibraryModel]]] = {}
self.manufacturer_lookup: dict[str, set[str]] = {}
self.profile_hashes: dict[str, str] = {}
async def initialize(self) -> None:
"""Initialize the loader."""
integration = await async_get_integration(self.hass, DOMAIN)
powercalc_version = AwesomeVersion(integration.version)
self.library_contents = await self.load_library_json()
self.profile_hashes = await self.hass.async_add_executor_job(self._load_profile_hashes)
self.model_infos.clear()
self.model_lookup.clear()
self.manufacturer_models.clear()
self.manufacturer_lookup.clear()
manufacturers: list[LibraryManufacturer] = self.library_contents.get("manufacturers", [])
for manufacturer in manufacturers:
manufacturer_name = str(manufacturer.get("dir_name"))
models: list[LibraryModel] = manufacturer.get("models", []) or []
# manufacturer alias map (alias -> {canonical manufacturer_name})
self.manufacturer_lookup.setdefault(manufacturer_name.lower(), set()).add(manufacturer_name)
for alias in manufacturer.get("aliases", []) or []:
self.manufacturer_lookup.setdefault(str(alias).lower(), set()).add(manufacturer_name)
# per-manufacturer model lookup
kept_models: list[LibraryModel] = []
lookup: dict[str, list[LibraryModel]] = {}
for model in models:
min_version = model.get("min_version")
model_id = str(model.get("id"))
model_id_lower = model_id.lower()
self.model_infos[f"{manufacturer_name}/{model_id!s}"] = model
if min_version and powercalc_version < AwesomeVersion(min_version):
_LOGGER.debug(
"Skipping model %s/%s as it requires powercalc version %s (current: %s)",
manufacturer_name,
model_id,
min_version,
powercalc_version,
)
continue
kept_models.append(model)
# Exact id bucket first (highest priority)
bucket = lookup.setdefault(model_id_lower, [])
bucket.insert(0, model)
# Alias buckets afterwards (lower priority)
for alias in model.get("aliases", []) or []:
alias_lower = str(alias).lower()
if alias_lower == model_id_lower:
continue
# Append to the end to ensure aliased models are always last
lookup.setdefault(alias_lower, []).append(model)
self.manufacturer_models[manufacturer_name] = kept_models
self.model_lookup[manufacturer_name] = lookup
async def load_library_json(self) -> dict[str, Any]:
"""Load library.json file"""
local_path = self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, "library.json")
def _load_local_library_json() -> dict[str, Any]:
"""Load library.json file from local storage"""
if not os.path.exists(local_path):
raise ProfileDownloadError("Local library.json file not found")
with open(local_path) as f:
return cast(dict[str, Any], json.load(f))
async def _download_remote_library_json() -> dict[str, Any] | None:
"""
Download library.json from Github.
If download is successful, save it to local storage to use as fallback in case of internet connection issues.
"""
_LOGGER.debug("Loading library.json from github")
session = async_get_clientsession(self.hass)
try:
async with asyncio.timeout(TIMEOUT_SECONDS), session.get(ENDPOINT_LIBRARY) as resp:
if resp.status != 200:
raise ProfileDownloadError(
f"Failed to download library.json, unexpected status code: {resp.status}",
)
data = await resp.read()
except (TimeoutError, ClientError) as err:
raise ProfileDownloadError(f"Failed to download library.json: {err}") from err
def _save_to_local_storage(data: bytes) -> None:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with open(local_path, "wb") as f:
f.write(data)
await self.hass.async_add_executor_job(_save_to_local_storage, data)
return cast(dict[str, Any], json.loads(data))
try:
return cast(dict[str, Any], await self.download_with_retry(_download_remote_library_json))
except ProfileDownloadError:
_LOGGER.debug("Failed to download library.json, falling back to local copy")
return await self.hass.async_add_executor_job(_load_local_library_json)
@async_cache
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of available manufacturers."""
return {
(manufacturer["dir_name"], manufacturer["full_name"])
for manufacturer in self.library_contents.get("manufacturers", [])
if any(self._model_matches_filters(model, device_types, discovery_by) for model in manufacturer.get("models", []))
}
@async_cache
async def find_manufacturers(self, search: str) -> set[str]:
"""Find the manufacturer in the library."""
return self.manufacturer_lookup.get(search, set())
@async_cache
async def get_model_listing(
self,
manufacturer: str,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None = None,
) -> set[tuple[str, str]]:
"""Get listing of available models and display names for a given manufacturer."""
models = self.manufacturer_models.get(manufacturer)
if not models:
return set()
return {
(model["id"], str(model.get("name") or model["id"]))
for model in self.manufacturer_models.get(manufacturer, [])
if self._model_matches_filters(model, device_types, discovery_by)
}
@staticmethod
def _model_matches_filters(
model: LibraryModel,
device_types: set[DeviceType] | None,
discovery_by: DiscoveryBy | None,
) -> bool:
model_device_type = DeviceType(model.get("device_type", DeviceType.LIGHT))
if device_types and model_device_type not in device_types:
return False
model_discovery_by = DiscoveryBy(model.get("discovery_by", DiscoveryBy.ENTITY))
return not discovery_by or model_discovery_by == discovery_by
@async_cache
async def find_model(self, manufacturer: str, search: set[str]) -> list[str]:
"""Find matching model IDs in the library."""
models = self.model_lookup.get(manufacturer, {})
return [model["id"] for phrase in search if (phrase_lower := phrase.lower()) in models for model in models[phrase_lower]]
@async_cache
async def find_model_migration(self, manufacturer: str, model: str) -> str | None:
"""Find the canonical model id for a legacy profile id."""
model_lower = model.lower()
matches = {
str(model_data.get("id"))
for manufacturer_data in self.library_contents.get("manufacturers", [])
if str(manufacturer_data.get("dir_name", "")).lower() == manufacturer
for model_data in manufacturer_data.get("models", []) or []
if model_lower in {str(legacy_id).lower() for legacy_id in model_data.get("legacy_ids", []) or []}
}
if len(matches) != 1:
return None
return next(iter(matches))
@async_cache
async def load_model(
self,
manufacturer: str,
model: str,
force_update: bool = False,
retry_count: int = 0,
) -> tuple[dict, str] | None:
"""Load a model, downloading it if necessary, with retry logic."""
model_info = self._get_library_model(manufacturer, model)
storage_path = self.get_storage_path(manufacturer, model)
model_path = os.path.join(storage_path, "model.json")
if await self._needs_update(model_info, manufacturer, model, model_path, force_update):
await self._download_profile_with_retry(manufacturer, model, storage_path, model_path)
try:
json_data = await self._load_model_json(model_path)
except JSONDecodeError as e:
return await self._handle_json_decode_error(e, manufacturer, model, retry_count)
return json_data, storage_path
def _get_library_model(self, manufacturer: str, model: str) -> LibraryModel:
"""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)
return model_info
async def _needs_update(self, model_info: LibraryModel, manufacturer: str, model: str, model_path: str, force_update: bool) -> bool:
"""Check if the model needs to be updated."""
if force_update:
return True
path_exists = os.path.exists(model_path)
if not path_exists:
return True
existing_hash = self.profile_hashes.get(f"{manufacturer}/{model}")
new_hash = model_info.get("hash")
return existing_hash != new_hash
async def _download_profile_with_retry(self, manufacturer: str, model: str, storage_path: str, model_path: str) -> None:
"""Attempt to download the profile, with retry logic and error handling."""
try:
model_info = self._get_library_model(manufacturer, model)
model_hash = str(model_info.get("hash"))
callback = partial(self.download_profile, manufacturer, model, storage_path, model_hash)
await self.download_with_retry(callback)
self.profile_hashes[f"{manufacturer}/{model}"] = model_hash
await self.hass.async_add_executor_job(self._write_profile_hashes, self.profile_hashes)
except ProfileDownloadError as e:
if not os.path.exists(model_path):
if os.path.exists(storage_path):
await self.hass.async_add_executor_job(shutil.rmtree, storage_path) # pragma: no cover
raise e
_LOGGER.debug("Failed to download profile, falling back to local profile")
async def _load_model_json(self, model_path: str) -> dict:
"""Load the JSON data from the model file."""
def _load_json() -> dict[str, Any]:
with open(model_path) as f:
return cast(dict[str, Any], json.load(f))
return await self.hass.async_add_executor_job(_load_json)
async def _handle_json_decode_error(
self,
error: JSONDecodeError,
manufacturer: str,
model: str,
retry_count: int,
) -> tuple[dict, str] | None:
"""Handle JSON decode errors with retry logic."""
_LOGGER.error("model.json file is not valid JSON for manufacturer: %s, model: %s", manufacturer, model)
if retry_count < 2:
_LOGGER.debug("Retrying to load model.json file")
return await self.load_model(manufacturer, model, True, retry_count + 1)
raise LibraryLoadingError("Failed to load model.json file") from error
def get_storage_path(self, manufacturer: str, model: str) -> str:
"""Retrieve the storage path for a given manufacturer and model."""
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, manufacturer, model))
async def download_with_retry(self, callback: Callable[[], Coroutine[Any, Any, None | dict[str, Any]]]) -> None | dict[str, Any]:
"""Download a file from a remote endpoint with retries"""
max_retries = 3
retry_count = 0
while retry_count < max_retries:
try:
return await callback()
except (ClientError, TimeoutError, ProfileDownloadError) as e:
_LOGGER.debug(e)
retry_count += 1
if retry_count == max_retries:
raise ProfileDownloadError(f"Failed to download even after {max_retries} retries, falling back to local copy") from e
await asyncio.sleep(self.retry_timeout)
_LOGGER.warning("Failed to download, retrying... (Attempt %d of %d)", retry_count + 1, max_retries)
return None # pragma: no cover
async def download_profile(self, manufacturer: str, model: str, storage_path: str, model_hash: str) -> None:
"""
Download the profile from Github using the Powercalc download API
Saves the profile to manufacturer/model directory in .storage/powercalc_profiles folder
"""
_LOGGER.debug("Downloading profile: %s/%s from github", manufacturer, model)
endpoint = f"{ENDPOINT_DOWNLOAD}/{manufacturer}/{model}"
def _save_file(data: bytes, directory: str) -> None:
"""Save file from Github to local storage directory"""
path = os.path.join(storage_path, directory)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
session = async_get_clientsession(self.hass)
try:
async with asyncio.timeout(TIMEOUT_SECONDS):
async with session.get(endpoint, params={"hash": model_hash}) as resp:
if resp.status != 200:
raise ProfileDownloadError(f"Failed to download profile: {manufacturer}/{model}")
resources = await resp.json()
await self.hass.async_add_executor_job(lambda: os.makedirs(storage_path, exist_ok=True))
# Download the files
for resource in resources:
url = resource.get("url")
async with session.get(url) as resp:
if resp.status != 200:
raise ProfileDownloadError(f"Failed to download github URL: {url}")
contents = await resp.read()
await self.hass.async_add_executor_job(_save_file, contents, resource.get("path"))
except (TimeoutError, aiohttp.ClientError) as e:
raise ProfileDownloadError(f"Failed to download profile: {manufacturer}/{model}") from e
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")
if not os.path.exists(path):
return {}
with open(path) as f:
return json.load(f) # type: ignore
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")
with open(path, "w") as json_file:
json.dump(hashes, json_file, indent=4)
@@ -0,0 +1,440 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
import logging
import os
from typing import Any, cast
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.lawn_mower import DOMAIN as LAWN_MOWER_DOMAIN
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import translation
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.helpers.typing import ConfigType
from custom_components.powercalc.const import (
BUILT_IN_LIBRARY_DIR,
CONF_MAX_POWER,
CONF_MIN_POWER,
CONF_POWER,
DOMAIN,
CalculationStrategy,
PowerProfileSource,
)
from custom_components.powercalc.errors import (
ModelNotSupportedError,
UnsupportedStrategyError,
)
from custom_components.powercalc.power_profile.sub_profile_selector import SubProfileSelectConfig
_LOGGER = logging.getLogger(__name__)
class DeviceType(StrEnum):
CAMERA = "camera"
COVER = "cover"
FAN = "fan"
GENERIC_IOT = "generic_iot"
LIGHT = "light"
POWER_METER = "power_meter"
PRINTER = "printer"
SMART_DIMMER = "smart_dimmer"
SMART_SWITCH = "smart_switch"
SMART_SPEAKER = "smart_speaker"
TELEVISION = "television"
NETWORK = "network"
VACUUM_ROBOT = "vacuum_robot"
LAWN_MOWER_ROBOT = "lawn_mower_robot"
HEATING = "heating"
UPS = "ups"
class DiscoveryBy(StrEnum):
DEVICE = "device"
ENTITY = "entity"
@dataclass(frozen=True)
class CustomField:
key: str
label: str
selector: dict[str, Any]
description: str | None = None
default: Any = None
DEVICE_TYPE_DOMAIN: dict[DeviceType, str | set[str]] = {
DeviceType.CAMERA: CAMERA_DOMAIN,
DeviceType.COVER: COVER_DOMAIN,
DeviceType.FAN: FAN_DOMAIN,
DeviceType.GENERIC_IOT: {SENSOR_DOMAIN, MEDIA_PLAYER_DOMAIN},
DeviceType.LIGHT: LIGHT_DOMAIN,
DeviceType.POWER_METER: SENSOR_DOMAIN,
DeviceType.SMART_DIMMER: LIGHT_DOMAIN,
DeviceType.SMART_SWITCH: {SWITCH_DOMAIN, LIGHT_DOMAIN},
DeviceType.SMART_SPEAKER: MEDIA_PLAYER_DOMAIN,
DeviceType.TELEVISION: MEDIA_PLAYER_DOMAIN,
DeviceType.NETWORK: BINARY_SENSOR_DOMAIN,
DeviceType.PRINTER: SENSOR_DOMAIN,
DeviceType.VACUUM_ROBOT: VACUUM_DOMAIN,
DeviceType.LAWN_MOWER_ROBOT: LAWN_MOWER_DOMAIN,
DeviceType.HEATING: CLIMATE_DOMAIN,
DeviceType.UPS: SENSOR_DOMAIN,
}
SUPPORTED_DOMAINS: set[str] = {domain for domains in DEVICE_TYPE_DOMAIN.values() for domain in (domains if isinstance(domains, set) else {domains})}
def _build_domain_device_type_mapping() -> Mapping[str, set[DeviceType]]:
"""Get the device types for a given entity domain."""
domain_to_device_type: defaultdict[str, set[DeviceType]] = defaultdict(set)
for device_type, domains in DEVICE_TYPE_DOMAIN.items():
domain_set = domains if isinstance(domains, set) else {domains}
for domain in domain_set:
domain_to_device_type[domain].add(device_type)
return domain_to_device_type
DOMAIN_DEVICE_TYPE_MAPPING: Mapping[str, set[DeviceType]] = _build_domain_device_type_mapping()
class PowerProfile:
def __init__(
self,
hass: HomeAssistant,
manufacturer: str,
model: str,
directory: str,
json_data: ConfigType,
sub_profiles: list[tuple[str, dict]] | None = None,
) -> None:
self._manufacturer = manufacturer
self._model = model.replace("#slash#", "/")
self._hass = hass
self._directory = directory
self._json_data = json_data
self.sub_profile: str | None = None
self._sub_profile_dir: str | None = None
self._sub_profiles = sub_profiles or []
def get_model_directory(self, root_only: bool = False) -> str:
"""Get the model directory containing the data files."""
if root_only:
return self._directory
return self._sub_profile_dir or self._directory
@property
def manufacturer(self) -> str:
"""Get the manufacturer of this profile."""
return self._manufacturer
@property
def model(self) -> str:
"""Get the model of this profile."""
return self._model
@property
def unique_id(self) -> str:
"""Get the unique id of this profile."""
return self._json_data.get("unique_id") or f"{self._manufacturer}_{self._model}"
@property
def name(self) -> str:
"""Get the name of this profile."""
return self._json_data.get("name") or ""
@property
def json_data(self) -> ConfigType:
"""Get the raw json data."""
return self._json_data
@property
def standby_power(self) -> float:
"""Get the standby power when the device is off."""
return self._json_data.get("standby_power") or 0
@property
def standby_power_on(self) -> float:
"""Get the standby power (self usage) when the device is on."""
standby_power_on = self._json_data.get("standby_power_on")
if standby_power_on is None and self.only_self_usage:
return self.standby_power
return standby_power_on or 0
@property
def calculation_strategy(self) -> CalculationStrategy:
"""Get the calculation strategy this profile provides"""
return CalculationStrategy(str(self._json_data.get("calculation_strategy", CalculationStrategy.LUT)))
@property
def linked_profile(self) -> str | None:
"""Get the linked profile."""
return self._json_data.get("linked_profile", self._json_data.get("linked_lut"))
@property
def calculation_enabled_condition(self) -> str | None:
"""Get the condition to enable the calculation."""
return self._json_data.get("calculation_enabled_condition")
@property
def aliases(self) -> list[str]:
"""Get a list of aliases for this model."""
return self._json_data.get("aliases") or []
@property
def linear_config(self) -> ConfigType | None:
"""Get configuration to set up linear strategy."""
config = self.get_strategy_config(CalculationStrategy.LINEAR)
if config is None:
return {CONF_MIN_POWER: 0, CONF_MAX_POWER: 0}
return config
@property
def min_version(self) -> str | None:
"""Get the minimum required version for this profile."""
return self._json_data.get("min_version") # pragma: no cover
@property
def multi_switch_config(self) -> ConfigType | None:
"""Get configuration to set up multi_switch strategy."""
return self.get_strategy_config(CalculationStrategy.MULTI_SWITCH)
@property
def fixed_config(self) -> ConfigType | None:
"""Get configuration to set up fixed strategy."""
config = self.get_strategy_config(CalculationStrategy.FIXED)
if config is None and self.standby_power_on:
return {CONF_POWER: 0}
return config
@property
def composite_config(self) -> list | None:
"""Get configuration to set up composite strategy."""
return cast(list, self._json_data.get("composite_config"))
@property
def playbook_config(self) -> ConfigType | None:
"""Get configuration to set up playbook strategy."""
return self.get_strategy_config(CalculationStrategy.PLAYBOOK)
def get_strategy_config(self, strategy: CalculationStrategy) -> ConfigType | None:
"""Get configuration for a certain strategy."""
if not self.is_strategy_supported(strategy):
raise UnsupportedStrategyError(
f"Strategy {strategy} is not supported by model: {self._model}",
)
return self._json_data.get(f"{strategy}_config")
@property
def sensor_config(self) -> ConfigType:
"""Additional sensor configuration."""
return self._json_data.get("sensor_config") or {}
def is_strategy_supported(self, mode: CalculationStrategy) -> bool:
"""Whether a certain calculation strategy is supported by this profile."""
return mode == self.calculation_strategy
@property
def needs_fixed_config(self) -> bool:
"""Used for smart switches which only provides standby power values.
This indicates the user must supply the power values in the config flow.
"""
if self.only_self_usage:
return False
return self.is_strategy_supported(
CalculationStrategy.FIXED,
) and not self._json_data.get("fixed_config")
@property
def needs_linear_config(self) -> bool:
"""
Used for smart dimmers. This indicates the user must supply the power values in the config flow.
"""
if self.only_self_usage:
return False
return self.is_strategy_supported(
CalculationStrategy.LINEAR,
) and not self._json_data.get("linear_config")
@property
def device_type(self) -> DeviceType | None:
"""Get the device type of this profile."""
device_type = self._json_data.get("device_type")
if not device_type:
return DeviceType.LIGHT
try:
return DeviceType(device_type)
except ValueError:
_LOGGER.warning("Unknown device type: %s", device_type)
return None
@property
def discovery_by(self) -> DiscoveryBy:
return DiscoveryBy(self._json_data.get("discovery_by", DiscoveryBy.ENTITY))
@property
def only_self_usage(self) -> bool:
"""Whether this profile only provides self usage."""
return bool(self._json_data.get("only_self_usage", False))
@property
def has_custom_fields(self) -> bool:
"""Whether this profile has custom fields."""
return bool(self._json_data.get("fields"))
@property
def custom_fields(self) -> list[CustomField]:
"""Get the custom fields of this profile."""
return [CustomField(key=key, **field) for key, field in self._json_data.get("fields", {}).items()]
@property
def documentation_url(self) -> str | None:
"""Get the documentation URL for this profile."""
return self._json_data.get("documentation_url")
@property
def config_flow_discovery_remarks(self) -> str | None:
"""Get remarks to show at the config flow discovery step."""
remarks = self._json_data.get("config_flow_discovery_remarks")
if not remarks:
translation_key = self.get_default_discovery_remarks_translation_key()
if translation_key:
translations = translation.async_get_cached_translations(
self._hass,
self._hass.config.language,
"common",
DOMAIN,
)
return translations.get(f"component.{DOMAIN}.common.{translation_key}")
return remarks
@property
def config_flow_sub_profile_remarks(self) -> str | None:
"""Get extra remarks to show at the config flow sub profile step."""
return self._json_data.get("config_flow_sub_profile_remarks")
@property
def compatible_integrations(self) -> list[str] | None:
"""Get the list of compatible integrations for this profile."""
return self._json_data.get("compatible_integrations")
def get_default_discovery_remarks_translation_key(self) -> str | None:
"""When no remarks are provided in the profile, see if we need to show a default remark."""
if self.device_type == DeviceType.SMART_SWITCH and self.needs_fixed_config:
return "remarks_smart_switch"
if self.device_type == DeviceType.SMART_DIMMER and self.needs_linear_config:
return "remarks_smart_dimmer"
return None
async def get_sub_profiles(self) -> list[tuple[str, dict]]:
"""Get listing of possible sub profiles and their corresponding JSON data."""
return self._sub_profiles
@property
async def has_sub_profiles(self) -> bool:
"""Check whether this profile has sub profiles."""
return len(await self.get_sub_profiles()) > 0
@property
async def requires_manual_sub_profile_selection(self) -> bool:
"""Check whether this profile requires manual sub profile selection."""
if not await self.has_sub_profiles:
return False
return not self.has_sub_profile_select_matchers
@property
def sub_profile_select(self) -> SubProfileSelectConfig | None:
"""Get the configuration for automatic sub profile switching."""
select_dict = self._json_data.get("sub_profile_select")
if not select_dict:
return None
return SubProfileSelectConfig(**select_dict)
@property
def has_sub_profile_select_matchers(self) -> bool:
"""Check whether the sub profile select has matchers."""
if not self.sub_profile_select:
return False
return bool(self.sub_profile_select.matchers)
async def select_sub_profile(self, sub_profile: str) -> None:
"""Select a sub profile. Only applicable when to profile actually supports sub profiles."""
if not await self.has_sub_profiles:
return
# Sub profile already selected, no need to load it again
if self.sub_profile == sub_profile:
return
sub_profiles = await self.get_sub_profiles()
found_profile = None
for sub_dir, json_data in sub_profiles:
if sub_dir == sub_profile:
found_profile = json_data
break
if found_profile is None:
raise ModelNotSupportedError(
f"Sub profile not found (manufacturer: {self._manufacturer}, model: {self._model}, sub_profile: {sub_profile})",
)
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.sub_profile = sub_profile
@property
async def needs_user_configuration(self) -> bool:
"""Check whether this profile needs user configuration."""
if self.calculation_strategy == CalculationStrategy.MULTI_SWITCH:
return True
if self.needs_fixed_config or self.needs_linear_config:
return True
if self.has_custom_fields:
return True
return await self.has_sub_profiles and not self.sub_profile_select
def is_entity_domain_supported(self, entity_entry: RegistryEntry) -> bool:
"""Check whether this power profile supports a given entity domain."""
if self.device_type is None:
return False
domain = entity_entry.domain
# see https://github.com/bramstroker/homeassistant-powercalc/issues/2529
if self.device_type == DeviceType.PRINTER and entity_entry.unit_of_measurement:
return False
return self.device_type in DOMAIN_DEVICE_TYPE_MAPPING[domain]
@property
def is_custom_profile(self) -> bool:
"""Whether this profile is a custom profile."""
return not self._directory.startswith(self._hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR))
@property
def configuration_source(self) -> PowerProfileSource:
return PowerProfileSource.LIBRARY_CUSTOM if self.is_custom_profile else PowerProfileSource.LIBRARY_BUILTIN
@@ -0,0 +1,243 @@
from __future__ import annotations
from enum import StrEnum
import re
from typing import Any, NamedTuple, Protocol
from homeassistant.core import HomeAssistant, State
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.errors import PowercalcSetupError
class SubProfileMatcherType(StrEnum):
ATTRIBUTE = "attribute"
ENTITY_ID = "entity_id"
ENTITY_REGISTRY = "entity_registry"
ENTITY_STATE = "entity_state"
INTEGRATION = "integration"
MODEL_ID = "model_id"
class SubProfileSelector:
def __init__(
self,
hass: HomeAssistant,
config: SubProfileSelectConfig,
source_entity: SourceEntity,
) -> None:
self._hass = hass
self._config = config
self._source_entity = source_entity
self._matchers: list[SubProfileMatcher] = self._build_matchers()
def _build_matchers(self) -> list[SubProfileMatcher]:
"""Create matchers from json config."""
return [self._create_matcher(matcher_config) for matcher_config in self._config.matchers or []]
def select_sub_profile(self, entity_state: State) -> str:
"""Dynamically tries to select a sub profile depending on the entity state.
This method always need to return a sub profile, when nothing is matched it will return a default.
"""
for matcher in self._matchers:
sub_profile = matcher.match(entity_state, self._source_entity)
if sub_profile:
return sub_profile
return self._config.default
def get_tracking_entities(self) -> list[str]:
"""Get additional list of entities to track for state changes."""
return [entity_id for matcher in self._matchers for entity_id in matcher.get_tracking_entities()]
def _create_matcher(self, matcher_config: dict) -> SubProfileMatcher:
"""Create a matcher from json config. Can be extended for more matchers in the future."""
matcher_type: SubProfileMatcherType = matcher_config["type"]
matcher_classes: dict[SubProfileMatcherType, type[SubProfileMatcher]] = {
SubProfileMatcherType.ATTRIBUTE: AttributeMatcher,
SubProfileMatcherType.ENTITY_STATE: EntityStateMatcher,
SubProfileMatcherType.ENTITY_ID: EntityIdMatcher,
SubProfileMatcherType.ENTITY_REGISTRY: EntityRegistryMatcher,
SubProfileMatcherType.INTEGRATION: IntegrationMatcher,
SubProfileMatcherType.MODEL_ID: ModelIdMatcher,
}
if matcher_type not in matcher_classes:
raise PowercalcSetupError(f"Unknown sub profile matcher type: {matcher_type}")
return matcher_classes[matcher_type].from_config(
matcher_config,
hass=self._hass,
source_entity=self._source_entity,
)
class SubProfileSelectConfig(NamedTuple):
default: str
matchers: list[dict] | None = None
class SubProfileMatcher(Protocol):
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> SubProfileMatcher: # noqa: ANN401
"""Create a matcher from a config dict."""
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
"""Returns a sub profile."""
def get_tracking_entities(self) -> list[str]:
"""Get extra entities to track for state changes."""
class EntityStateMatcher(SubProfileMatcher):
def __init__(
self,
hass: HomeAssistant,
source_entity: SourceEntity | None,
entity_id: str,
mapping: dict[str, str],
) -> None:
self._hass = hass
if source_entity:
entity_id = entity_id.replace(
"{{source_object_id}}",
source_entity.object_id,
)
self._entity_id = entity_id
self._mapping = mapping
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
state = self._hass.states.get(self._entity_id)
if state is None:
return None
return self._mapping.get(state.state)
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityStateMatcher: # noqa: ANN401
return cls(kwargs["hass"], kwargs["source_entity"], config["entity_id"], config["map"])
def get_tracking_entities(self) -> list[str]:
return [self._entity_id]
class AttributeMatcher(SubProfileMatcher):
def __init__(self, attribute: str, mapping: dict[str, str]) -> None:
self._attribute = attribute
self._mapping = mapping
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
val = entity_state.attributes.get(self._attribute)
if val is None:
return None
return self._mapping.get(val)
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> AttributeMatcher: # noqa: ANN401
return cls(config["attribute"], config["map"])
def get_tracking_entities(self) -> list[str]:
return []
class EntityIdMatcher(SubProfileMatcher):
def __init__(self, pattern: str, profile: str) -> None:
self._pattern = pattern
self._profile = profile
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
if re.search(self._pattern, entity_state.entity_id):
return self._profile
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityIdMatcher: # noqa: ANN401
return cls(config["pattern"], config["profile"])
def get_tracking_entities(self) -> list[str]:
return []
class IntegrationMatcher(SubProfileMatcher):
def __init__(self, integration: str, profile: str) -> None:
self._integration = integration
self._profile = profile
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
registry_entry = source_entity.entity_entry
if not registry_entry:
return None
if registry_entry.platform == self._integration:
return self._profile
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> IntegrationMatcher: # noqa: ANN401
return cls(config["integration"], config["profile"])
def get_tracking_entities(self) -> list[str]:
return []
class EntityRegistryMatcher(SubProfileMatcher):
def __init__(self, property_name: str, value: object, profile: str) -> None:
self._property_name = property_name
self._value = value
self._profile = profile
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
registry_entry = source_entity.entity_entry
if not registry_entry or not hasattr(registry_entry, self._property_name):
return None
registry_value = getattr(registry_entry, self._property_name)
if registry_value is None:
return None
if self._matches_registry_value(registry_value):
return self._profile
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> EntityRegistryMatcher: # noqa: ANN401
return cls(config["property"], config["value"], config["profile"])
def get_tracking_entities(self) -> list[str]:
return []
def _matches_registry_value(self, registry_value: object) -> bool:
if registry_value == self._value:
return True
if isinstance(registry_value, list | set | tuple | frozenset):
return self._value in registry_value
return str(registry_value) == str(self._value)
class ModelIdMatcher(SubProfileMatcher):
def __init__(self, model_id: str, profile: str) -> None:
self._model_id = model_id
self._profile = profile
def match(self, entity_state: State, source_entity: SourceEntity) -> str | None:
device_entry = source_entity.device_entry
if not device_entry:
return None
if device_entry.model_id == self._model_id:
return self._profile
return None
@classmethod
def from_config(cls, config: dict, **kwargs: Any) -> ModelIdMatcher: # noqa: ANN401
return cls(config["model_id"], config["profile"])
def get_tracking_entities(self) -> list[str]:
return []
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant
from custom_components.powercalc.common import create_source_entity
from custom_components.powercalc.const import CONF_MODEL, CONF_SUB_PROFILE
from custom_components.powercalc.flow_helper.schema import build_sub_profile_schema
from custom_components.powercalc.power_profile.factory import get_power_profile
class SubProfileRepairFlow(RepairsFlow):
"""Handler for sub profile correction."""
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
self._config_entry = config_entry
self._hass = hass
async def async_step_init(
self,
user_input: dict[str, str] | None = None,
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_sub_profile()
async def async_step_sub_profile(self, user_input: dict[str, str] | None = None) -> data_entry_flow.FlowResult:
if user_input is not None:
new_data = self._config_entry.data.copy()
new_data[CONF_MODEL] = f"{new_data[CONF_MODEL]}/{user_input[CONF_SUB_PROFILE]}"
self._hass.config_entries.async_update_entry(self._config_entry, data=new_data)
return self.async_create_entry(title="", data={})
source_entity = await create_source_entity(self._config_entry.data[CONF_ENTITY_ID], self._hass)
profile = await get_power_profile(self.hass, dict(self._config_entry.data), source_entity)
assert profile
sub_profile_schema = await build_sub_profile_schema(profile, None)
remarks = profile.config_flow_sub_profile_remarks
if remarks:
remarks = "\n\n" + remarks
return self.async_show_form(
step_id="sub_profile",
data_schema=sub_profile_schema,
description_placeholders={
"entity_id": source_entity.entity_id,
"remarks": remarks or "",
"model": profile.model,
"manufacturer": profile.manufacturer,
},
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
assert data
config_entry_id = str(data["config_entry_id"])
config_entry = hass.config_entries.async_get_entry(config_entry_id)
assert config_entry
return SubProfileRepairFlow(hass, config_entry)
+79
View File
@@ -0,0 +1,79 @@
"""Platform for sensor integration."""
from __future__ import annotations
from collections.abc import Callable
import logging
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from custom_components.powercalc import DOMAIN
from custom_components.powercalc.analytics.analytics import collect_analytics
from custom_components.powercalc.const import DATA_ENTITY_TYPES, EntityType
SIGNAL_CREATE_SELECT_ENTITIES = "powercalc_create_select_entities_{}"
DATA_PENDING_SELECT_ENTITIES = "powercalc_pending_select_entities"
def _key(entry: ConfigEntry | None) -> str:
return entry.entry_id if entry else ""
_LOGGER = logging.getLogger(__name__)
def delayed_add_entities_handler(
hass: HomeAssistant,
async_add_entities: AddEntitiesCallback,
entry: ConfigEntry | None = None,
) -> Callable[[], None]:
@callback
def _handle_new_entities(entities: list[SelectEntity]) -> None:
_LOGGER.debug("Adding TariffSelect entities signal")
collect_analytics(hass, entry).inc(DATA_ENTITY_TYPES, EntityType.TARIFF_SELECT)
async_add_entities(entities)
return async_dispatcher_connect(
hass,
SIGNAL_CREATE_SELECT_ENTITIES.format(_key(entry)),
_handle_new_entities,
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Setup sensors from YAML config sensor entries."""
key = _key(None)
pending = hass.data[DOMAIN].setdefault(DATA_PENDING_SELECT_ENTITIES, {}).pop(key, [])
if pending: # pragma: no cover
_LOGGER.debug("Adding TariffSelect entities pending")
collect_analytics(hass, None).inc(DATA_ENTITY_TYPES, EntityType.TARIFF_SELECT)
async_add_entities(pending)
delayed_add_entities_handler(hass, async_add_entities)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Setup sensors from config entry."""
key = _key(entry)
pending = hass.data[DOMAIN].setdefault(DATA_PENDING_SELECT_ENTITIES, {}).pop(key, [])
if pending:
_LOGGER.debug("Adding TariffSelect entities pending")
collect_analytics(hass, entry).inc(DATA_ENTITY_TYPES, EntityType.TARIFF_SELECT)
async_add_entities(pending)
unsub = delayed_add_entities_handler(hass, async_add_entities, entry)
entry.async_on_unload(unsub)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,155 @@
from __future__ import annotations
import logging
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
CONF_NAME,
__version__ as HA_VERSION, # noqa
)
from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.device_registry as dr
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_ENERGY_SENSOR_FRIENDLY_NAMING,
CONF_ENERGY_SENSOR_NAMING,
CONF_POWER_SENSOR_FRIENDLY_NAMING,
CONF_POWER_SENSOR_NAMING,
DEFAULT_ENERGY_NAME_PATTERN,
DEFAULT_POWER_NAME_PATTERN,
DOMAIN,
)
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
_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."""
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)
def generate_power_sensor_name(
sensor_config: ConfigType,
name: str | None = None,
source_entity: SourceEntity | None = None,
) -> str:
"""Generates the name to use for a power sensor."""
return _generate_sensor_name(
sensor_config,
CONF_POWER_SENSOR_NAMING,
CONF_POWER_SENSOR_FRIENDLY_NAMING,
name,
source_entity,
)
def generate_energy_sensor_name(
sensor_config: ConfigType,
name: str | None = None,
source_entity: SourceEntity | None = None,
) -> str:
"""Generates the name to use for an energy sensor."""
return _generate_sensor_name(
sensor_config,
CONF_ENERGY_SENSOR_NAMING,
CONF_ENERGY_SENSOR_FRIENDLY_NAMING,
name,
source_entity,
)
def _generate_sensor_name(
sensor_config: ConfigType,
naming_conf_key: str,
friendly_naming_conf_key: str,
name: str | None = None,
source_entity: SourceEntity | None = None,
) -> str:
"""Generates the name to use for a sensor."""
if name is None and source_entity:
name = source_entity.name
if friendly_naming_conf_key in sensor_config:
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,
),
)
return name_pattern.format(name)
@callback
def generate_power_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 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,
)
@callback
def generate_energy_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 an energy 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))
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,
)
def get_entity_id_by_unique_id(
hass: HomeAssistant,
unique_id: str | None,
) -> str | None:
if unique_id is None:
return None
entity_reg = er.async_get(hass)
return entity_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id)
@@ -0,0 +1,301 @@
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
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import (
CONF_NAME,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
UnitOfEnergy,
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
import voluptuous as vol
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.const import (
CONF_DAILY_FIXED_ENERGY,
CONF_ENERGY_SENSOR_CATEGORY,
CONF_ENERGY_SENSOR_PRECISION,
CONF_ENERGY_SENSOR_UNIT_PREFIX,
CONF_FIXED,
CONF_ON_TIME,
CONF_POWER,
CONF_START_TIME,
CONF_UPDATE_FREQUENCY,
CONF_VALUE,
DEFAULT_ENERGY_SENSOR_PRECISION,
UnitPrefix,
)
from .abstract import generate_energy_sensor_entity_id, generate_energy_sensor_name
from .energy import EnergySensor
from .power import VirtualPowerSensor, create_virtual_power_sensor
ENERGY_ICON = "mdi:lightning-bolt"
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
DEFAULT_DAILY_UPDATE_FREQUENCY = 1800
DAILY_FIXED_ENERGY_SCHEMA = vol.Schema(
{
vol.Required(CONF_VALUE): vol.Any(vol.Coerce(float), cv.template),
vol.Optional(
CONF_UNIT_OF_MEASUREMENT,
default=UnitOfEnergy.KILO_WATT_HOUR,
): vol.In(
[UnitOfEnergy.KILO_WATT_HOUR, UnitOfPower.WATT],
),
vol.Optional(CONF_ON_TIME, default=timedelta(days=1)): cv.time_period,
vol.Optional(CONF_START_TIME): cv.time,
vol.Optional(
CONF_UPDATE_FREQUENCY,
default=DEFAULT_DAILY_UPDATE_FREQUENCY,
): vol.Coerce(int),
},
)
_LOGGER = logging.getLogger(__name__)
async def create_daily_fixed_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
source_entity: SourceEntity | None = None,
) -> DailyEnergySensor:
mode_config: ConfigType = sensor_config.get(CONF_DAILY_FIXED_ENERGY) # type: ignore
name = generate_energy_sensor_name(
sensor_config,
sensor_config.get(CONF_NAME),
source_entity,
)
unique_id = sensor_config.get(CONF_UNIQUE_ID) or None
entity_id = generate_energy_sensor_entity_id(
hass,
sensor_config,
unique_id=unique_id,
source_entity=source_entity,
)
_LOGGER.debug(
"Creating daily_fixed_energy energy sensor (name=%s, entity_id=%s, unique_id=%s)",
name,
entity_id,
unique_id,
)
if CONF_ON_TIME in mode_config:
on_time = mode_config.get(CONF_ON_TIME)
if not isinstance(on_time, timedelta):
on_time = timedelta(seconds=on_time) # type: ignore
else:
on_time = timedelta(days=1)
return DailyEnergySensor(
hass,
name,
entity_id,
mode_config.get(CONF_VALUE), # type: ignore
str(mode_config.get(CONF_UNIT_OF_MEASUREMENT)),
int(mode_config.get(CONF_UPDATE_FREQUENCY, DEFAULT_DAILY_UPDATE_FREQUENCY)),
sensor_config,
on_time=on_time,
start_time=mode_config.get(CONF_START_TIME),
rounding_digits=int(sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION)),
)
async def create_daily_fixed_energy_power_sensor(
hass: HomeAssistant,
sensor_config: dict,
source_entity: SourceEntity,
) -> VirtualPowerSensor | None:
mode_config: dict = sensor_config.get(CONF_DAILY_FIXED_ENERGY) # type: ignore
if mode_config.get(CONF_ON_TIME) != timedelta(days=1):
return None
power_value: float = mode_config.get(CONF_VALUE) # type: ignore
if mode_config.get(CONF_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR and not isinstance(power_value, Template):
power_value = power_value * 1000 / 24
power_sensor_config = sensor_config.copy()
power_sensor_config[CONF_FIXED] = {CONF_POWER: power_value}
unique_id = sensor_config.get(CONF_UNIQUE_ID)
if unique_id:
power_sensor_config[CONF_UNIQUE_ID] = f"{unique_id}_power"
_LOGGER.debug(
"Creating daily_fixed_energy power sensor (base_name=%s unique_id=%s)",
sensor_config.get(CONF_NAME),
unique_id,
)
return await create_virtual_power_sensor(
hass,
power_sensor_config,
source_entity,
None,
)
class DailyEnergySensor(RestoreEntity, SensorEntity, EnergySensor):
_attr_device_class = SensorDeviceClass.ENERGY
_attr_state_class = SensorStateClass.TOTAL
_attr_should_poll = False
_attr_icon = ENERGY_ICON
def __init__(
self,
hass: HomeAssistant,
name: str,
entity_id: str,
value: float | Template,
user_unit_of_measurement: str,
update_frequency: int,
sensor_config: dict[str, Any],
on_time: timedelta | None = None,
start_time: time | None = None,
rounding_digits: int = 4,
) -> None:
self._hass = hass
self._attr_name = name
self._state: Decimal = Decimal(0)
self._attr_entity_category = sensor_config.get(CONF_ENERGY_SENSOR_CATEGORY)
self._value = value
self._user_unit_of_measurement = user_unit_of_measurement
self._update_frequency = update_frequency
self._sensor_config = sensor_config
self._on_time = on_time or timedelta(days=1)
self._start_time = start_time
self._rounding_digits = rounding_digits
self._attr_suggested_display_precision = self._rounding_digits
self._attr_unique_id = sensor_config.get(CONF_UNIQUE_ID)
self.entity_id = entity_id
self._last_updated: float = dt_util.utcnow().timestamp()
self._last_delta_calculate: float | None = None
self.set_native_unit_of_measurement()
self._update_timer_removal: Callable[[], None] | None = None
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
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
if state := await self.async_get_last_state():
try:
self._state = Decimal(state.state)
except decimal.DecimalException:
_LOGGER.warning(
"%s: Cannot restore state: %s",
self.entity_id,
state.state,
)
self._state = Decimal(0)
self._last_updated = state.last_changed.timestamp()
self._state += self.calculate_delta()
self.async_schedule_update_ha_state()
else:
self._state = Decimal(0)
_LOGGER.debug("%s: Restoring state: %s", self.entity_id, self._state)
@callback
def refresh(__: datetime) -> None:
"""Update the energy sensor state."""
delta = self.calculate_delta(self._update_frequency)
if delta > 0:
self._state = self._state + delta
_LOGGER.debug(
"%s: Updating daily_fixed_energy sensor: %.4f",
self.entity_id,
self._state,
)
self.async_schedule_update_ha_state()
self._last_updated = dt_util.now().timestamp()
self._update_timer_removal = async_track_time_interval(
self.hass,
refresh,
timedelta(seconds=self._update_frequency),
)
def calculate_delta(self, elapsed_seconds: int = 0) -> Decimal:
if self._last_delta_calculate is None:
self._last_delta_calculate = self._last_updated
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)
wh_per_day = value * (self._on_time.total_seconds() / 3600) if self._user_unit_of_measurement == UnitOfPower.WATT else value * 1000
# 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
return Decimal((energy_per_day / 86400) * elapsed_seconds)
@property
def native_value(self) -> Decimal:
"""Return the state of the sensor."""
return Decimal(round(self._state, self._rounding_digits))
@callback
def async_reset(self) -> None:
_LOGGER.debug("%s: Reset energy sensor", self.entity_id)
self._state = Decimal(0)
self._attr_last_reset = dt_util.utcnow()
self.async_write_ha_state()
async def async_increase(self, value: str) -> None:
_LOGGER.debug("%s: Increasing energy sensor with %s", self.entity_id, value)
self._state += Decimal(value)
self.async_write_ha_state()
async def async_calibrate(self, value: str) -> None:
_LOGGER.debug("%s: Calibrate energy sensor with %s", self.entity_id, value)
self._state = Decimal(value)
self.async_write_ha_state()
@@ -0,0 +1,373 @@
from __future__ import annotations
from datetime import timedelta
from decimal import Decimal
import inspect
import logging
from typing import Any
from homeassistant.components.integration.sensor import IntegrationSensor
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass
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
from custom_components.powercalc.common import SourceEntity
from custom_components.powercalc.const import (
ATTR_SOURCE_DOMAIN,
ATTR_SOURCE_ENTITY,
CONF_DISABLE_EXTENDED_ATTRIBUTES,
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_PRECISION,
CONF_ENERGY_SENSOR_UNIT_PREFIX,
CONF_ENERGY_UPDATE_INTERVAL,
CONF_FORCE_ENERGY_SENSOR_CREATION,
CONF_POWER_SENSOR_ID,
DEFAULT_ENERGY_INTEGRATION_METHOD,
DEFAULT_ENERGY_SENSOR_PRECISION,
DEFAULT_ENERGY_UPDATE_INTERVAL,
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
from .abstract import (
BaseEntity,
generate_energy_sensor_entity_id,
generate_energy_sensor_name,
)
from .power import PowerSensor, RealPowerSensor
ENERGY_ICON = "mdi:lightning-bolt"
ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}"
_LOGGER = logging.getLogger(__name__)
async def create_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
power_sensor: PowerSensor,
source_entity: SourceEntity | None = None,
) -> EnergySensor:
"""Create the energy sensor entity."""
# Check for existing energy sensor
energy_sensor = await _get_existing_energy_sensor(hass, sensor_config)
if energy_sensor:
return energy_sensor
# Check if we should find or create a related energy sensor
energy_sensor = await _get_related_energy_sensor(hass, sensor_config, power_sensor)
if energy_sensor:
return energy_sensor
# Create a new virtual energy sensor based on the virtual power sensor
return await _create_virtual_energy_sensor(hass, sensor_config, power_sensor, source_entity)
async def _get_existing_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
) -> EnergySensor | None:
"""Check if the user specified an 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,
)
async def _get_related_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
power_sensor: PowerSensor,
) -> EnergySensor | None:
"""Find or create a related energy sensor based on the power sensor."""
if CONF_POWER_SENSOR_ID not in sensor_config or not isinstance(power_sensor, RealPowerSensor):
return None
if sensor_config.get(CONF_FORCE_ENERGY_SENSOR_CREATION):
_LOGGER.debug(
"Forced energy sensor generation for the power sensor '%s'",
power_sensor.entity_id,
)
return None
real_energy_sensor = _find_related_real_energy_sensor(hass, power_sensor)
if real_energy_sensor:
_LOGGER.debug(
"Found existing energy sensor '%s' for the power sensor '%s'",
real_energy_sensor.entity_id,
power_sensor.entity_id,
)
return real_energy_sensor
_LOGGER.debug(
"No existing energy sensor found for the power sensor '%s'",
power_sensor.entity_id,
)
return None
async def _create_virtual_energy_sensor(
hass: HomeAssistant,
sensor_config: ConfigType,
power_sensor: PowerSensor,
source_entity: SourceEntity | None,
) -> VirtualEnergySensor:
"""Create a virtual energy sensor using riemann integral integration."""
name = generate_energy_sensor_name(
sensor_config,
sensor_config.get(CONF_NAME),
source_entity,
)
unique_id = f"{power_sensor.unique_id}_energy" if power_sensor.unique_id is not None else None
entity_id = generate_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 energy sensor (entity_id=%s, source_entity=%s, unit_prefix=%s)",
entity_id,
power_sensor.entity_id,
unit_prefix,
)
return VirtualEnergySensor(
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 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),
)
def get_unit_prefix(
hass: HomeAssistant,
sensor_config: ConfigType,
power_sensor: PowerSensor,
) -> str | None:
unit_prefix = sensor_config.get(CONF_ENERGY_SENSOR_UNIT_PREFIX)
power_unit = UnitOfPower(power_sensor.unit_of_measurement) # type: ignore
power_state = hass.states.get(power_sensor.entity_id)
if power_unit is None and power_state: # type: ignore
power_unit = power_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # type: ignore # pragma: no cover
# When the power sensor is in kW, we don't want to add an extra k prefix.
# As this would result in an energy sensor having kkWh unit, which is obviously invalid
if power_unit == UnitOfPower.KILO_WATT and unit_prefix == UnitPrefix.KILO:
unit_prefix = UnitPrefix.NONE
if unit_prefix == UnitPrefix.NONE:
unit_prefix = None
return unit_prefix
@callback
def _find_related_real_energy_sensor(
hass: HomeAssistant,
power_sensor: RealPowerSensor,
) -> RealEnergySensor | None:
"""See if a corresponding energy sensor exists in the HA installation for the power sensor."""
if not power_sensor.device_id:
return None
ent_reg = er.async_get(hass)
energy_sensors = [
entry
for entry in er.async_entries_for_device(
ent_reg,
device_id=power_sensor.device_id,
)
if entry.device_class == SensorDeviceClass.ENERGY or entry.unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR
]
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,
)
class EnergySensor(BaseEntity):
"""Class which all energy sensors should extend from."""
class VirtualEnergySensor(IntegrationSensor, EnergySensor):
"""Virtual energy sensor, totalling kWh."""
_attr_state_class = SensorStateClass.TOTAL_INCREASING
_unrecorded_attributes = frozenset({ATTR_SOURCE_DOMAIN, ATTR_SOURCE_ENTITY})
def __init__(
self,
hass: HomeAssistant,
source_entity: str,
entity_id: str,
sensor_config: ConfigType,
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,
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)
params = {
"hass": hass,
"source_entity": source_entity,
"name": name,
"round_digits": round_digits,
"unit_prefix": unit_prefix,
"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)),
}
signature = inspect.signature(IntegrationSensor.__init__)
params = {key: val for key, val in params.items() if key in signature.parameters}
super().__init__(**params) # type: ignore[arg-type]
self._powercalc_source_entity = powercalc_source_entity
self._powercalc_source_domain = powercalc_source_domain
self._sensor_config = sensor_config
self.entity_id = entity_id
self._attr_device_class = SensorDeviceClass.ENERGY
self._attr_suggested_display_precision = round_digits
if entity_category:
self._attr_entity_category = EntityCategory(entity_category)
self._filter_outliers = bool(sensor_config.get(CONF_ENERGY_FILTER_OUTLIER_ENABLED, False))
self._outlier_filter = OutlierFilter(
window_size=30,
min_samples=5,
max_z_score=3.5,
max_expected_step=sensor_config.get(CONF_ENERGY_FILTER_OUTLIER_MAX, 1000),
)
def _integrate_on_state_change(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
"""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
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
super()._integrate_on_state_change(*args, **kwargs)
@property
def extra_state_attributes(self) -> dict[str, str] | None:
"""Return the state attributes of the energy sensor."""
if self._sensor_config.get(CONF_DISABLE_EXTENDED_ATTRIBUTES):
return super().extra_state_attributes
if self._powercalc_source_entity is None:
return None
attrs = {
ATTR_SOURCE_ENTITY: self._powercalc_source_entity or "",
ATTR_SOURCE_DOMAIN: self._powercalc_source_domain or "",
}
super_attrs = super().extra_state_attributes
if super_attrs:
attrs.update(super_attrs)
return attrs
@property
def icon(self) -> str:
return ENERGY_ICON
@callback
def async_reset(self) -> None:
_LOGGER.debug("%s: Reset energy sensor", self.entity_id)
self._state = Decimal(0)
self.async_write_ha_state()
async def async_calibrate(self, value: str) -> None:
_LOGGER.debug("%s: Calibrate energy sensor to: %s", self.entity_id, value)
self._state = Decimal(value)
self.async_write_ha_state()
class RealEnergySensor(EnergySensor):
"""Contains a reference to an existing energy sensor entity."""
def __init__(
self,
entity_id: str,
name: str | None = None,
unique_id: str | None = None,
) -> None:
self.entity_id = entity_id
self._name = name
self._unique_id = unique_id
@property
def name(self) -> str | None:
"""Return the name of the sensor."""
return self._name
@property
def unique_id(self) -> str | None:
"""Return the unique_id of the sensor."""
return self._unique_id

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