Initial Commit
This commit is contained in:
@@ -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}",
|
||||
},
|
||||
),
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,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,
|
||||
),
|
||||
),
|
||||
},
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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])
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 []
|
||||
@@ -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)
|
||||
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
Reference in New Issue
Block a user