diff --git a/.ha_run.lock b/.ha_run.lock index 3887e9f..3aa1f83 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 71, "version": 1, "ha_version": "2026.6.3", "start_ts": 1781389664.1672328} \ No newline at end of file +{"pid": 71, "version": 1, "ha_version": "2026.6.3", "start_ts": 1781531265.230539} \ No newline at end of file diff --git a/custom_components/powercalc/__init__.py b/custom_components/powercalc/__init__.py index 1f90091..2559ae2 100644 --- a/custom_components/powercalc/__init__.py +++ b/custom_components/powercalc/__init__.py @@ -33,7 +33,11 @@ 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 .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, @@ -190,7 +194,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.critical(msg) return False - global_config = await get_global_configuration(hass, config) + global_config = get_global_configuration(hass, config) discovery_manager = await create_discovery_manager_instance(hass, config, global_config) hass.data[DOMAIN] = { @@ -205,7 +209,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DATA_ANALYTICS: {}, } - await register_services(hass) + register_services(hass) await async_load_platform(hass, Platform.SELECT, DOMAIN, {}, config) await setup_yaml_sensors(hass, config, global_config) @@ -258,7 +262,9 @@ async def create_discovery_manager_instance( 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_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) @@ -273,7 +279,7 @@ async def create_discovery_manager_instance( return manager -async def register_services(hass: HomeAssistant) -> None: +def register_services(hass: HomeAssistant) -> None: """Register generic services""" async def _handle_change_gui_service(call: ServiceCall) -> None: @@ -309,7 +315,7 @@ async def register_services(hass: HomeAssistant) -> None: 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) + hass.data[DOMAIN][DOMAIN_CONFIG] = get_global_configuration(hass, reload_config) # Reload YAML sensors if any if DOMAIN in reload_config: @@ -328,7 +334,7 @@ async def register_services(hass: HomeAssistant) -> None: _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) + global_config = get_global_configuration(hass, reload_config) setup_domain_groups(hass, global_config) await create_standby_group(hass, global_config) @@ -517,7 +523,7 @@ async def async_remove_entry(hass: HomeAssistant, config_entry: 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( + updated_entries = remove_power_sensor_from_associated_groups( hass, config_entry, ) @@ -545,7 +551,7 @@ async def repair_none_config_entries_issue(hass: HomeAssistant) -> None: 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 + hass.config_entries._entries._index_entry(entry) # noqa: SLF001 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 diff --git a/custom_components/powercalc/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/__pycache__/__init__.cpython-314.pyc index c5477f0..189e403 100644 Binary files a/custom_components/powercalc/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/common.cpython-314.pyc b/custom_components/powercalc/__pycache__/common.cpython-314.pyc index cf829fe..a2d4106 100644 Binary files a/custom_components/powercalc/__pycache__/common.cpython-314.pyc and b/custom_components/powercalc/__pycache__/common.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/config_flow.cpython-314.pyc b/custom_components/powercalc/__pycache__/config_flow.cpython-314.pyc index e8a246e..ed5d908 100644 Binary files a/custom_components/powercalc/__pycache__/config_flow.cpython-314.pyc and b/custom_components/powercalc/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/const.cpython-314.pyc b/custom_components/powercalc/__pycache__/const.cpython-314.pyc index 669c5f7..1c0a068 100644 Binary files a/custom_components/powercalc/__pycache__/const.cpython-314.pyc and b/custom_components/powercalc/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/device_binding.cpython-314.pyc b/custom_components/powercalc/__pycache__/device_binding.cpython-314.pyc index cef1198..c074f83 100644 Binary files a/custom_components/powercalc/__pycache__/device_binding.cpython-314.pyc and b/custom_components/powercalc/__pycache__/device_binding.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/diagnostics.cpython-314.pyc b/custom_components/powercalc/__pycache__/diagnostics.cpython-314.pyc index 67ac3bd..fe05aa9 100644 Binary files a/custom_components/powercalc/__pycache__/diagnostics.cpython-314.pyc and b/custom_components/powercalc/__pycache__/diagnostics.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/discovery.cpython-314.pyc b/custom_components/powercalc/__pycache__/discovery.cpython-314.pyc index 00170e6..ee2be8e 100644 Binary files a/custom_components/powercalc/__pycache__/discovery.cpython-314.pyc and b/custom_components/powercalc/__pycache__/discovery.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/errors.cpython-314.pyc b/custom_components/powercalc/__pycache__/errors.cpython-314.pyc index 7b62d31..88c349b 100644 Binary files a/custom_components/powercalc/__pycache__/errors.cpython-314.pyc and b/custom_components/powercalc/__pycache__/errors.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/helpers.cpython-314.pyc b/custom_components/powercalc/__pycache__/helpers.cpython-314.pyc index 804a0bf..146081f 100644 Binary files a/custom_components/powercalc/__pycache__/helpers.cpython-314.pyc and b/custom_components/powercalc/__pycache__/helpers.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/migrate.cpython-314.pyc b/custom_components/powercalc/__pycache__/migrate.cpython-314.pyc index 8182565..4056d8e 100644 Binary files a/custom_components/powercalc/__pycache__/migrate.cpython-314.pyc and b/custom_components/powercalc/__pycache__/migrate.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/repairs.cpython-314.pyc b/custom_components/powercalc/__pycache__/repairs.cpython-314.pyc index cb59521..e78fb80 100644 Binary files a/custom_components/powercalc/__pycache__/repairs.cpython-314.pyc and b/custom_components/powercalc/__pycache__/repairs.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/select.cpython-314.pyc b/custom_components/powercalc/__pycache__/select.cpython-314.pyc index e11068a..15b2c7d 100644 Binary files a/custom_components/powercalc/__pycache__/select.cpython-314.pyc and b/custom_components/powercalc/__pycache__/select.cpython-314.pyc differ diff --git a/custom_components/powercalc/__pycache__/sensor.cpython-314.pyc b/custom_components/powercalc/__pycache__/sensor.cpython-314.pyc index ae1d65d..d08aefb 100644 Binary files a/custom_components/powercalc/__pycache__/sensor.cpython-314.pyc and b/custom_components/powercalc/__pycache__/sensor.cpython-314.pyc differ diff --git a/custom_components/powercalc/analytics/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/analytics/__pycache__/__init__.cpython-314.pyc index 5ad726d..cb74bf9 100644 Binary files a/custom_components/powercalc/analytics/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/analytics/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/analytics/__pycache__/analytics.cpython-314.pyc b/custom_components/powercalc/analytics/__pycache__/analytics.cpython-314.pyc index 2b5aa2d..69222e3 100644 Binary files a/custom_components/powercalc/analytics/__pycache__/analytics.cpython-314.pyc and b/custom_components/powercalc/analytics/__pycache__/analytics.cpython-314.pyc differ diff --git a/custom_components/powercalc/analytics/analytics.py b/custom_components/powercalc/analytics/analytics.py index b6a20f6..77cb6fc 100644 --- a/custom_components/powercalc/analytics/analytics.py +++ b/custom_components/powercalc/analytics/analytics.py @@ -12,7 +12,7 @@ import uuid import aiohttp from homeassistant.config_entries import ConfigEntry -from homeassistant.const import __version__ as HA_VERSION # noqa +from homeassistant.const import __version__ as HA_VERSION # noqa: N812 from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -123,7 +123,7 @@ class AnalyticsCollector: counter: Counter[Hashable] = self._data.setdefault(key, Counter()) # type:ignore counter[value] += 1 - def add(self, key: str, value: Any) -> None: # noqa: ANN401 + def add(self, key: str, value: object) -> None: """Add value to listing""" if self._already_seen(key) or value is None: return @@ -194,7 +194,11 @@ class Analytics: 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), + ( + e.created_at + for e in entity_registry.async_get(self.hass).entities.values() + if e.domain != DOMAIN and e.created_at > cutoff + ), ) try: diff --git a/custom_components/powercalc/common.py b/custom_components/powercalc/common.py index 20a1c7d..f0c5e7f 100644 --- a/custom_components/powercalc/common.py +++ b/custom_components/powercalc/common.py @@ -46,7 +46,7 @@ def is_number(value: str) -> bool: return math.isfinite(fvalue) -async def create_source_entity(entity_id: str, hass: HomeAssistant) -> SourceEntity: +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) @@ -61,7 +61,9 @@ async def create_source_entity(entity_id: str, hass: HomeAssistant) -> SourceEnt 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 + 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] = [] @@ -69,13 +71,14 @@ async def create_source_entity(entity_id: str, hass: HomeAssistant) -> SourceEnt 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] + supported_color_modes = entity_entry.capabilities.get( 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) + supported_color_modes = entity_state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, []) return SourceEntity( source_object_id, @@ -147,13 +150,20 @@ def get_merged_sensor_configuration(*configs: dict, validate: bool = True) -> di 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)) + 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: + 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", ) diff --git a/custom_components/powercalc/config_flow.py b/custom_components/powercalc/config_flow.py index 9e8e9ca..f82de91 100644 --- a/custom_components/powercalc/config_flow.py +++ b/custom_components/powercalc/config_flow.py @@ -4,8 +4,9 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable +from inspect import isawaitable import logging -from typing import Any, cast +from typing import Any import uuid from homeassistant.config_entries import ( @@ -21,8 +22,7 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, ) -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.schema_config_entry_flow import SchemaFlowError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -47,7 +47,12 @@ from .const import ( ) 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.daily_energy import ( + SCHEMA_DAILY_ENERGY_OPTIONS, + DailyEnergyConfigFlow, + DailyEnergyOptionsFlow, + build_daily_energy_config, +) from .flow_helper.flows.global_configuration import ( GlobalConfigurationConfigFlow, GlobalConfigurationOptionsFlow, @@ -65,6 +70,7 @@ from .flow_helper.flows.virtual_power import ( VirtualPowerConfigFlow, VirtualPowerOptionsFlow, ) +from .flow_helper.profile_preview import async_setup_preview as async_setup_powercalc_preview from .flow_helper.schema import ( SCHEMA_ENERGY_SENSOR_TOGGLE, SCHEMA_SENSOR_ENERGY_OPTIONS, @@ -158,9 +164,14 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow): super().__init__() + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up the config flow preview websocket command.""" + await async_setup_powercalc_preview(hass) + @abstractmethod @callback - def persist_config_entry(self) -> FlowResult: + def persist_config_entry(self) -> ConfigFlowResult: pass # pragma: no cover def _async_step(self, step: Step) -> Callable: @@ -174,12 +185,12 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow): return _async_step - async def async_step(self, step: Step, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step(self, step: Step, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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 + return await getattr(handler, step_method)(user_input) # type: ignore[no-any-return] raise SchemaFlowError("No handler defined") # pragma: nocover async def validate_strategy_config(self, user_input: dict[str, Any] | None = None) -> None: @@ -188,8 +199,14 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow): self.sensor_config.get(CONF_MODE) or self.selected_profile.calculation_strategy, # type: ignore ) factory = PowerCalculatorStrategyFactory(self.hass) + assert self.source_entity is not None try: - await factory.create(user_input or self.sensor_config, strategy_name, self.selected_profile, self.source_entity) # type: ignore + await factory.create( + user_input or self.sensor_config, + strategy_name, + self.selected_profile, + self.source_entity, + ) except StrategyConfigurationError as error: _LOGGER.error(str(error)) raise SchemaFlowError(error.get_config_flow_translate_key() or "unknown") from error @@ -227,12 +244,13 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow): self, form_step: PowercalcFormStep, user_input: dict[str, Any] | None = None, - ) -> FlowResult: + ) -> ConfigFlowResult: """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) + validated_input = form_step.validate_user_input(user_input) + user_input = await validated_input if isawaitable(validated_input) else validated_input except SchemaFlowError as exc: return await self._show_form(form_step, exc) @@ -243,14 +261,15 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow): 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) + resolved_next_step = form_step.next_step(user_input) + next_step = await resolved_next_step if isawaitable(resolved_next_step) else resolved_next_step 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 getattr(self, f"async_step_{next_step}")() # type: ignore[no-any-return] return await self._show_form(form_step) @@ -258,15 +277,15 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow): self, skip_advanced: bool = False, skip_utility_meter_options: bool = False, - ) -> FlowResult: + ) -> ConfigFlowResult: """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 + return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_power_advanced() # type: ignore[no-any-return] 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: + async def _show_form(self, form_step: PowercalcFormStep, error: SchemaFlowError | None = None) -> ConfigFlowResult: # Show form for next step last_step = None if not callable(form_step.next_step): @@ -288,9 +307,12 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow): 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() + schema = await form_step.schema() + if schema is None: + return vol.Schema({}) + return schema - async def async_step_utility_meter_options(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_utility_meter_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the flow for utility meter options.""" return await self.handle_form_step( PowercalcFormStep( @@ -300,7 +322,7 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow): user_input, ) - async def async_step_energy_options(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_energy_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the flow for utility meter options.""" return await self.handle_form_step( PowercalcFormStep( @@ -315,7 +337,7 @@ class PowercalcCommonFlow(ABC, ConfigEntryBaseFlow): class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN): """Handle a config flow for PowerCalc.""" - VERSION = 7 + VERSION = 8 def __init__(self) -> None: """Initialize options flow.""" @@ -367,13 +389,13 @@ class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN): 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 + return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_wled() # type: ignore[no-any-return] if len(power_profiles) > 1: - return cast(ConfigFlowResult, await self.flow_handlers[FlowType.LIBRARY].async_step_library_multi_profile()) + return await self.flow_handlers[FlowType.LIBRARY].async_step_library_multi_profile() # type: ignore[no-any-return] self.selected_profile = power_profiles[0] - return cast(ConfigFlowResult, await self.flow_handlers[FlowType.LIBRARY].async_step_library()) + return await self.flow_handlers[FlowType.LIBRARY].async_step_library() # type: ignore[no-any-return] async def async_step_user( self, @@ -394,15 +416,15 @@ class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN): 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: + async def async_step_menu_library(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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 + return await self.flow_handlers[FlowType.VIRTUAL_POWER].async_step_virtual_power(user_input) # type: ignore[no-any-return] @callback - def persist_config_entry(self) -> FlowResult: + def persist_config_entry(self) -> ConfigFlowResult: """Create the config entry.""" self.sensor_config.update({CONF_SENSOR_TYPE: self.selected_sensor_type}) self.sensor_config.update({CONF_NAME: self.name}) @@ -442,18 +464,21 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow): async def async_step_init( self, user_input: dict[str, Any] | None = None, - ) -> FlowResult: + ) -> ConfigFlowResult: """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()) + 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 = create_source_entity( self.source_entity_id, self.hass, ) @@ -463,7 +488,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow): return self.async_show_menu(step_id=Step.INIT, menu_options=self.build_menu()) - async def initialize_library_profile(self) -> FlowResult | None: + async def initialize_library_profile(self) -> ConfigFlowResult | 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) @@ -519,19 +544,29 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow): return True - async def async_step_basic_options(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_basic_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_advanced_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_utility_meter_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the basic options flow.""" - return await self.async_handle_options_step(user_input, SCHEMA_UTILITY_METER_OPTIONS, Step.UTILITY_METER_OPTIONS) + 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: + async def async_handle_options_step( + self, + user_input: dict[str, Any] | None, + schema: vol.Schema, + step: Step, + form_kwarg: dict[str, Any] | None = None, + ) -> ConfigFlowResult: """ Generic handler for all the option steps. processes user input against the select schema. @@ -543,11 +578,13 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow): 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) + return self.async_show_form(step_id=step, data_schema=schema, errors=errors, **(form_kwarg or {})) - def persist_config_entry(self) -> FlowResult: + def persist_config_entry(self) -> ConfigFlowResult: """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 + 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, @@ -597,7 +634,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow): ) -> 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. + Update current_config with the new options. Used to save the data to the config entry later. """ for key in schema.schema: if isinstance(key, vol.Marker): @@ -632,7 +669,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow): {vol.Optional(CONF_STANDBY_POWER): vol.Coerce(float)}, ) - return schema.extend( # type: ignore + return schema.extend( # type: ignore[no-any-return] { **SCHEMA_ENERGY_SENSOR_TOGGLE.schema, **SCHEMA_UTILITY_METER_TOGGLE.schema, diff --git a/custom_components/powercalc/configuration/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/configuration/__pycache__/__init__.cpython-314.pyc index e63851f..9413c88 100644 Binary files a/custom_components/powercalc/configuration/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/configuration/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/configuration/__pycache__/global_config.cpython-314.pyc b/custom_components/powercalc/configuration/__pycache__/global_config.cpython-314.pyc index 4f25d1d..094b10f 100644 Binary files a/custom_components/powercalc/configuration/__pycache__/global_config.cpython-314.pyc and b/custom_components/powercalc/configuration/__pycache__/global_config.cpython-314.pyc differ diff --git a/custom_components/powercalc/configuration/global_config.py b/custom_components/powercalc/configuration/global_config.py index ef6d0aa..c9293a0 100644 --- a/custom_components/powercalc/configuration/global_config.py +++ b/custom_components/powercalc/configuration/global_config.py @@ -53,7 +53,7 @@ FLAG_HAS_GLOBAL_GUI_CONFIG = "has_global_gui_config" _LOGGER = logging.getLogger(__name__) -async def get_global_configuration(hass: HomeAssistant, config: ConfigType) -> ConfigType: +def get_global_configuration(hass: HomeAssistant, config: ConfigType) -> ConfigType: # Default configuration values default_config = { CONF_ENABLE_ANALYTICS: False, @@ -96,8 +96,8 @@ async def get_global_configuration(hass: HomeAssistant, config: ConfigType) -> C 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) + handle_legacy_discovery_config(hass, global_config, yaml_config) + handle_legacy_update_interval_config(hass, global_config, yaml_config) return global_config diff --git a/custom_components/powercalc/const.py b/custom_components/powercalc/const.py index 7a2a879..7dbdec4 100644 --- a/custom_components/powercalc/const.py +++ b/custom_components/powercalc/const.py @@ -17,7 +17,7 @@ from homeassistant.const import ( EntityCategory, ) -MIN_HA_VERSION = "2025.1" +MIN_HA_VERSION = "2026.1" BUILT_IN_LIBRARY_DIR = "powercalc_profiles" @@ -170,6 +170,11 @@ CONF_UTILITY_METER_TARIFFS = "utility_meter_tariffs" CONF_UTILITY_METER_TYPES = "utility_meter_types" CONF_VALUE = "value" CONF_VALUE_TEMPLATE = "value_template" + +# Wrapper keys for ChooseSelector config flow forms (not stored in config entries). +CONF_FIXED_VALUE = "fixed_value" +CONF_DAILY_ENERGY_VALUE = "daily_energy_value" +CONF_PLAYBOOK_ID = "playbook_id" CONF_VARIABLES = "variables" CONF_VOLTAGE = "voltage" CONF_WILDCARD = "wildcard" @@ -198,7 +203,6 @@ class UnitPrefix(StrEnum): ENTITY_CATEGORIES = [ - EntityCategory.CONFIG, EntityCategory.DIAGNOSTIC, None, ] @@ -225,10 +229,12 @@ API_URL = "https://api.powercalc.nl" MANUFACTURER_WLED = "WLED" ATTR_CALCULATION_MODE = "calculation_mode" +ATTR_MEMBERS = "members" ATTR_ENERGY_SENSOR_ENTITY_ID = "energy_sensor_entity_id" ATTR_ENTITIES = "entities" ATTR_INTEGRATION = "integration" ATTR_IS_GROUP = "is_group" +ATTR_STATE = "state" ATTR_SOURCE_ENTITY = "source_entity" ATTR_SOURCE_DOMAIN = "source_domain" @@ -236,6 +242,7 @@ 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_DEBUG_GROUP = "debug_group" SERVICE_GET_ACTIVE_PLAYBOOK = "get_active_playbook" SERVICE_GET_GROUP_ENTITIES = "get_group_entities" SERVICE_INCREASE_DAILY_ENERGY = "increase_daily_energy" diff --git a/custom_components/powercalc/device_binding.py b/custom_components/powercalc/device_binding.py index 038e24e..d41e371 100644 --- a/custom_components/powercalc/device_binding.py +++ b/custom_components/powercalc/device_binding.py @@ -4,7 +4,7 @@ from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE, - __version__ as HA_VERSION, # noqa + __version__ as HA_VERSION, # noqa: N812 ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry @@ -90,7 +90,11 @@ def remove_stale_devices( ) -def get_device_info(hass: HomeAssistant, sensor_config: ConfigType, source_entity: SourceEntity | None) -> DeviceInfo | None: +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 diff --git a/custom_components/powercalc/diagnostics.py b/custom_components/powercalc/diagnostics.py index f395807..d670464 100644 --- a/custom_components/powercalc/diagnostics.py +++ b/custom_components/powercalc/diagnostics.py @@ -20,7 +20,7 @@ async def async_get_config_entry_diagnostics( data: dict = { "entry": entry.as_dict(), - "config_entry_count_per_type": await get_count_by_sensor_type(hass), + "config_entry_count_per_type": get_count_by_sensor_type(hass), "yaml_config": await get_yaml_configuration(hass), } @@ -31,7 +31,7 @@ async def async_get_config_entry_diagnostics( return data -async def get_count_by_sensor_type(hass: HomeAssistant) -> dict[SensorType, int]: +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: diff --git a/custom_components/powercalc/discovery.py b/custom_components/powercalc/discovery.py index 42d549f..ba0698e 100644 --- a/custom_components/powercalc/discovery.py +++ b/custom_components/powercalc/discovery.py @@ -1,17 +1,17 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from datetime import timedelta +from datetime import datetime, timedelta from enum import StrEnum import logging import re -from typing import Any +from typing import Any, TypeVar 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.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import discovery_flow import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import EntityCategory @@ -33,13 +33,22 @@ from .const import ( MANUFACTURER_WLED, CalculationStrategy, ) -from .group_include.filter import CategoryFilter, CompositeFilter, DomainFilter, FilterOperator, LambdaFilter, NotFilter, get_filtered_entity_list +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__) +_DiscoverySourceT = TypeVar("_DiscoverySourceT", er.RegistryEntry, dr.DeviceEntry) async def get_power_profile_by_source_entity(hass: HomeAssistant, source_entity: SourceEntity) -> PowerProfile | None: @@ -102,6 +111,7 @@ class DiscoveryManager: 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._cancel_rediscover_interval: CALLBACK_TYPE | None = None self._status = DiscoveryStatus.NOT_STARTED if enabled else DiscoveryStatus.DISABLED async def setup(self) -> None: @@ -112,14 +122,18 @@ class DiscoveryManager: await self.start_discovery() - async def _rediscover(_: Any) -> None: # noqa: ANN401 + async def _rediscover(_: datetime) -> None: """Rediscover entities.""" await self.update_library_and_rediscover() - async_track_time_interval( + if self._cancel_rediscover_interval: # pragma: no cover + self._cancel_rediscover_interval() + + self._cancel_rediscover_interval = async_track_time_interval( self.hass, _rediscover, timedelta(hours=2), + cancel_on_shutdown=True, ) async def update_library_and_rediscover(self) -> None: @@ -130,6 +144,9 @@ class DiscoveryManager: async def start_discovery(self) -> None: """Start the discovery procedure.""" + if self._status == DiscoveryStatus.DISABLED: + _LOGGER.debug("Discovery manager is disabled, skipping discovery run") + return if self._status == DiscoveryStatus.IN_PROGRESS: _LOGGER.debug("Discovery already in progress, skipping new discovery run") return @@ -139,10 +156,10 @@ class DiscoveryManager: _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] + await self.perform_discovery(self.get_entities, self.create_entity_source, DiscoveryBy.ENTITY) _LOGGER.debug("Start device discovery") - await self.perform_discovery(self.get_devices, self.create_device_source, DiscoveryBy.DEVICE) # type: ignore[arg-type] + await self.perform_discovery(self.get_devices, self.create_device_source, DiscoveryBy.DEVICE) _LOGGER.debug("Done auto discovery") self._status = DiscoveryStatus.FINISHED @@ -158,7 +175,7 @@ class DiscoveryManager: if not entity_id or entity_id == DUMMY_ENTITY_ID: continue - entity = await create_source_entity(str(entity_id), self.hass) + entity = 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) @@ -173,13 +190,13 @@ class DiscoveryManager: async def perform_discovery( self, - source_provider: Callable[[], Awaitable[list]], - source_creator: Callable[[er.RegistryEntry | dr.DeviceEntry], Awaitable[SourceEntity]], + source_provider: Callable[[], Awaitable[list[_DiscoverySourceT]]], + source_creator: Callable[[_DiscoverySourceT], 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 + log_identifier = str(getattr(source, "entity_id", getattr(source, "id", "unknown"))) try: model_info = await self.extract_model_info_from_device_info(source) if not model_info: @@ -229,7 +246,7 @@ class DiscoveryManager: 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) + return create_source_entity(entity_entry.entity_id, self.hass) @staticmethod async def create_device_source(device_entry: dr.DeviceEntry) -> SourceEntity: @@ -267,13 +284,19 @@ class DiscoveryManager: power_profiles = [] for model_info in models: - profile = await get_power_profile(self.hass, {}, source_entity, model_info=model_info, process_variables=False) + 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 discovery_type == DiscoveryBy.ENTITY: + entity_entry = source_entity.entity_entry + if entity_entry is not None and not profile.is_entity_domain_supported(entity_entry): + continue if profile.device_type in self._exclude_device_types: continue if self._exclude_self_usage_profiles and profile.only_self_usage: @@ -294,7 +317,11 @@ class DiscoveryManager: """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) + 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)", @@ -352,9 +379,9 @@ class DiscoveryManager: ], FilterOperator.OR, ) - return await get_filtered_entity_list(self.hass, NotFilter(entity_filter)) + return get_filtered_entity_list(self.hass, NotFilter(entity_filter)) - async def get_devices(self) -> list: + async def get_devices(self) -> list[dr.DeviceEntry]: """Fetch device entries.""" return list(dr.async_get(self.hass).devices.values()) @@ -364,6 +391,9 @@ class DiscoveryManager: async def disable(self) -> None: """Disable the discovery.""" + if self._cancel_rediscover_interval: + self._cancel_rediscover_interval() + self._cancel_rediscover_interval = None self._status = DiscoveryStatus.DISABLED self.initialized_flows = set() flows = self.hass.config_entries.flow.async_progress_by_handler(DOMAIN) @@ -423,7 +453,9 @@ class DiscoveryManager: # 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 + 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 @@ -508,7 +540,9 @@ class DiscoveryManager: # 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] + 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)) @@ -521,7 +555,11 @@ class DiscoveryManager: # 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], + [ + str(entry.data.get(CONF_ENTITY_ID)) + for entry in self.hass.config_entries.async_entries(DOMAIN) + if entry.source == SOURCE_USER + ], ) return entities diff --git a/custom_components/powercalc/filter/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/filter/__pycache__/__init__.cpython-314.pyc index 67097b7..f1baac5 100644 Binary files a/custom_components/powercalc/filter/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/filter/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/filter/__pycache__/outlier.cpython-314.pyc b/custom_components/powercalc/filter/__pycache__/outlier.cpython-314.pyc index 603824f..36accef 100644 Binary files a/custom_components/powercalc/filter/__pycache__/outlier.cpython-314.pyc and b/custom_components/powercalc/filter/__pycache__/outlier.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/flow_helper/__pycache__/__init__.cpython-314.pyc index 90aa951..313812c 100644 Binary files a/custom_components/powercalc/flow_helper/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/flow_helper/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/__pycache__/common.cpython-314.pyc b/custom_components/powercalc/flow_helper/__pycache__/common.cpython-314.pyc index f8d7560..7e8dbad 100644 Binary files a/custom_components/powercalc/flow_helper/__pycache__/common.cpython-314.pyc and b/custom_components/powercalc/flow_helper/__pycache__/common.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/__pycache__/dynamic_field_builder.cpython-314.pyc b/custom_components/powercalc/flow_helper/__pycache__/dynamic_field_builder.cpython-314.pyc index ac12dfa..dc220d7 100644 Binary files a/custom_components/powercalc/flow_helper/__pycache__/dynamic_field_builder.cpython-314.pyc and b/custom_components/powercalc/flow_helper/__pycache__/dynamic_field_builder.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/__pycache__/profile_preview.cpython-314.pyc b/custom_components/powercalc/flow_helper/__pycache__/profile_preview.cpython-314.pyc new file mode 100644 index 0000000..51e93ac Binary files /dev/null and b/custom_components/powercalc/flow_helper/__pycache__/profile_preview.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/__pycache__/schema.cpython-314.pyc b/custom_components/powercalc/flow_helper/__pycache__/schema.cpython-314.pyc index 14ba532..a236e06 100644 Binary files a/custom_components/powercalc/flow_helper/__pycache__/schema.cpython-314.pyc and b/custom_components/powercalc/flow_helper/__pycache__/schema.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/common.py b/custom_components/powercalc/flow_helper/common.py index a73a277..e325faf 100644 --- a/custom_components/powercalc/flow_helper/common.py +++ b/custom_components/powercalc/flow_helper/common.py @@ -1,4 +1,4 @@ -from collections.abc import Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine import copy from dataclasses import dataclass from enum import StrEnum @@ -59,6 +59,9 @@ class FlowType(StrEnum): GLOBAL_CONFIGURATION = "global_configuration" +type MaybeAwaitable[R] = R | Awaitable[R] + + @dataclass(slots=True) class PowercalcFormStep: schema: vol.Schema | Callable[[], Coroutine[Any, Any, vol.Schema | None]] @@ -66,12 +69,12 @@ class PowercalcFormStep: validate_user_input: ( Callable[ [dict[str, Any]], - Coroutine[Any, Any, dict[str, Any]], + MaybeAwaitable[dict[str, Any]], ] | None ) = None - next_step: Step | Callable[[dict[str, Any]], Coroutine[Any, Any, Step | None]] | None = None + next_step: Step | Callable[[dict[str, Any]], MaybeAwaitable[Step | None]] | None = None continue_utility_meter_options_step: bool = False continue_advanced_step: bool = False form_kwarg: dict[str, Any] | None = None @@ -88,9 +91,85 @@ def fill_schema_defaults( 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 + new_key = vol.Optional(key.schema, default=options.get(key)) # type: ignore[call-overload] + elif isinstance(key, vol.Required): + new_key = vol.Required(key.schema, default=options.get(key)) # type: ignore[call-overload] + new_key.description = {"suggested_value": options.get(key)} # type: ignore[call-overload] elif "suggested_value" not in (new_key.description or {}): new_key = copy.copy(key) - new_key.description = {"suggested_value": options.get(key)} # type: ignore + new_key.description = {"suggested_value": options.get(key)} # type: ignore[call-overload] schema[new_key] = val return vol.Schema(schema) + + +def unwrap_choose_selector( + user_input: dict[str, Any], + wrapper_key: str, + value_key: str | Callable[[object], str] | None = None, +) -> dict[str, Any]: + """ + Unwrap a ChooseSelector value in user_input back into flat keys. + + A ChooseSelector value looks like {"active_choice": "", "": }. + The wrapper key is dropped, and the active choice's value is merged back into user_input. + Home Assistant schema validation returns the selected value directly; use ``value_key`` + to map that validated value back to a config key. + """ + if wrapper_key not in user_input: + return user_input + + raw = user_input.pop(wrapper_key) + if not isinstance(raw, dict): + if isinstance(value_key, str): + user_input[value_key] = raw + elif value_key is not None: + user_input[value_key(raw)] = raw + return user_input + + if "active_choice" not in raw: + user_input.update(raw) + return user_input + + active = raw["active_choice"] + value = raw.get(active) + if value is None: + return user_input + + if isinstance(value, dict): + user_input.update(value) + else: + user_input[active] = value + return user_input + + +def wrap_choose_selector( + form_data: dict[str, Any], + wrapper_key: str, + choices: dict[str, list[str] | str], + *, + raw_value: bool = False, +) -> dict[str, Any]: + """ + Build the ChooseSelector value for ``wrapper_key`` from existing flat ``form_data``. + + ``choices`` maps the choice id to either a single key (the value of that key becomes + the choice value) or a list of keys (a dict of those keys becomes the choice value). + The first choice that has a matching key in form_data is used. + """ + for choice_id, mapping in choices.items(): + keys = [mapping] if isinstance(mapping, str) else mapping + present = {key: form_data[key] for key in keys if key in form_data} + if not present: + continue + + if isinstance(mapping, str): + choice_value: Any = present[mapping] + else: + choice_value = present + + if raw_value: + return {**form_data, wrapper_key: choice_value} + + return {**form_data, wrapper_key: {"active_choice": choice_id, choice_id: choice_value}} + + return form_data diff --git a/custom_components/powercalc/flow_helper/dynamic_field_builder.py b/custom_components/powercalc/flow_helper/dynamic_field_builder.py index 3900f9c..8e107d7 100644 --- a/custom_components/powercalc/flow_helper/dynamic_field_builder.py +++ b/custom_components/powercalc/flow_helper/dynamic_field_builder.py @@ -7,7 +7,11 @@ 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: +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 @@ -22,7 +26,8 @@ def build_dynamic_field_schema(hass: HomeAssistant, profile: PowerProfile, sourc 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) + entity.entity_id + for entity in entity_reg.entities.get_entries_for_device_id(source_entity.device_entry.id) ] schema[key] = selector(field.selector) diff --git a/custom_components/powercalc/flow_helper/flows/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/flow_helper/flows/__pycache__/__init__.cpython-314.pyc index c1b03bf..72cd1f8 100644 Binary files a/custom_components/powercalc/flow_helper/flows/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/flow_helper/flows/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/flows/__pycache__/daily_energy.cpython-314.pyc b/custom_components/powercalc/flow_helper/flows/__pycache__/daily_energy.cpython-314.pyc index b1a55cb..a711c7f 100644 Binary files a/custom_components/powercalc/flow_helper/flows/__pycache__/daily_energy.cpython-314.pyc and b/custom_components/powercalc/flow_helper/flows/__pycache__/daily_energy.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/flows/__pycache__/global_configuration.cpython-314.pyc b/custom_components/powercalc/flow_helper/flows/__pycache__/global_configuration.cpython-314.pyc index ccf0a99..4ed1e72 100644 Binary files a/custom_components/powercalc/flow_helper/flows/__pycache__/global_configuration.cpython-314.pyc and b/custom_components/powercalc/flow_helper/flows/__pycache__/global_configuration.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/flows/__pycache__/group.cpython-314.pyc b/custom_components/powercalc/flow_helper/flows/__pycache__/group.cpython-314.pyc index f233430..675f1bb 100644 Binary files a/custom_components/powercalc/flow_helper/flows/__pycache__/group.cpython-314.pyc and b/custom_components/powercalc/flow_helper/flows/__pycache__/group.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/flows/__pycache__/library.cpython-314.pyc b/custom_components/powercalc/flow_helper/flows/__pycache__/library.cpython-314.pyc index 7aace5e..b6fc81e 100644 Binary files a/custom_components/powercalc/flow_helper/flows/__pycache__/library.cpython-314.pyc and b/custom_components/powercalc/flow_helper/flows/__pycache__/library.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/flows/__pycache__/real_power.cpython-314.pyc b/custom_components/powercalc/flow_helper/flows/__pycache__/real_power.cpython-314.pyc index e6c4868..0f15561 100644 Binary files a/custom_components/powercalc/flow_helper/flows/__pycache__/real_power.cpython-314.pyc and b/custom_components/powercalc/flow_helper/flows/__pycache__/real_power.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/flows/__pycache__/virtual_power.cpython-314.pyc b/custom_components/powercalc/flow_helper/flows/__pycache__/virtual_power.cpython-314.pyc index 42a5bba..ef45455 100644 Binary files a/custom_components/powercalc/flow_helper/flows/__pycache__/virtual_power.cpython-314.pyc and b/custom_components/powercalc/flow_helper/flows/__pycache__/virtual_power.cpython-314.pyc differ diff --git a/custom_components/powercalc/flow_helper/flows/daily_energy.py b/custom_components/powercalc/flow_helper/flows/daily_energy.py index b66a956..7f499ee 100644 --- a/custom_components/powercalc/flow_helper/flows/daily_energy.py +++ b/custom_components/powercalc/flow_helper/flows/daily_energy.py @@ -2,14 +2,22 @@ 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.config_entries import ConfigFlowResult +from homeassistant.const import ( + CONF_NAME, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + UnitOfEnergy, + UnitOfPower, + UnitOfTime, +) 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_ENERGY_VALUE, CONF_DAILY_FIXED_ENERGY, CONF_GROUP, CONF_ON_TIME, @@ -18,17 +26,35 @@ from custom_components.powercalc.const import ( CONF_VALUE_TEMPLATE, SensorType, ) -from custom_components.powercalc.flow_helper.common import PowercalcFormStep, Step, fill_schema_defaults +from custom_components.powercalc.flow_helper.common import ( + PowercalcFormStep, + Step, + fill_schema_defaults, + unwrap_choose_selector, + wrap_choose_selector, +) 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 +DAILY_ENERGY_VALUE_CHOICES: dict[str, list[str] | str] = { + CONF_VALUE_TEMPLATE: CONF_VALUE_TEMPLATE, + CONF_VALUE: CONF_VALUE, +} + SCHEMA_DAILY_ENERGY_OPTIONS = vol.Schema( { - vol.Optional(CONF_VALUE): vol.Coerce(float), - vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(), + vol.Optional(CONF_DAILY_ENERGY_VALUE): selector.ChooseSelector( + selector.ChooseSelectorConfig( + choices={ + CONF_VALUE: {"selector": {"number": {"mode": "box", "step": "any"}}}, + CONF_VALUE_TEMPLATE: {"selector": {"template": {}}}, + }, + translation_key=CONF_DAILY_ENERGY_VALUE, + ), + ), vol.Optional( CONF_UNIT_OF_MEASUREMENT, default=UnitOfEnergy.KILO_WATT_HOUR, @@ -58,13 +84,26 @@ SCHEMA_DAILY_ENERGY = vol.Schema( ).extend(SCHEMA_DAILY_ENERGY_OPTIONS.schema) +def daily_energy_choice_key_from_validated_value(value: object) -> str: + """Infer the daily energy config key from a validated ChooseSelector value.""" + return CONF_VALUE_TEMPLATE if isinstance(value, str) else CONF_VALUE + + def build_daily_energy_config(user_input: dict[str, Any], schema: vol.Schema) -> dict[str, Any]: """Build the config under daily_energy: key.""" + user_input = unwrap_choose_selector( + dict(user_input), + CONF_DAILY_ENERGY_VALUE, + daily_energy_choice_key_from_validated_value, + ) config: dict[str, Any] = { CONF_DAILY_FIXED_ENERGY: {}, } + schema_keys = {key.schema if isinstance(key, vol.Marker) else key for key in schema.schema} + schema_keys.discard(CONF_DAILY_ENERGY_VALUE) + schema_keys |= {CONF_VALUE, CONF_VALUE_TEMPLATE} for key, val in user_input.items(): - if key in schema.schema and val is not None: + if key in schema_keys and val is not None: if key in {CONF_CREATE_UTILITY_METERS, CONF_GROUP, CONF_NAME, CONF_UNIQUE_ID}: config[str(key)] = val continue @@ -80,14 +119,19 @@ class DailyEnergyConfigFlow: async def async_step_daily_energy( self, user_input: dict[str, Any] | None = None, - ) -> FlowResult: + ) -> ConfigFlowResult: """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: + def _validate(user_input: dict[str, Any]) -> dict[str, Any]: + unwrapped = unwrap_choose_selector( + dict(user_input), + CONF_DAILY_ENERGY_VALUE, + daily_energy_choice_key_from_validated_value, + ) + if CONF_VALUE not in unwrapped and CONF_VALUE_TEMPLATE not in unwrapped: raise SchemaFlowError("daily_energy_mandatory") - return build_daily_energy_config(user_input, SCHEMA_DAILY_ENERGY) + return build_daily_energy_config(unwrapped, SCHEMA_DAILY_ENERGY) return await self.flow.handle_form_step( PowercalcFormStep( @@ -104,10 +148,21 @@ 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: + async def async_step_daily_energy(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the daily energy options flow.""" + form_data = wrap_choose_selector( + dict(self.flow.sensor_config[CONF_DAILY_FIXED_ENERGY]), + CONF_DAILY_ENERGY_VALUE, + DAILY_ENERGY_VALUE_CHOICES, + ) schema = fill_schema_defaults( SCHEMA_DAILY_ENERGY_OPTIONS, - self.flow.sensor_config[CONF_DAILY_FIXED_ENERGY], + form_data, ) + if user_input is not None: + user_input = unwrap_choose_selector( + dict(user_input), + CONF_DAILY_ENERGY_VALUE, + daily_energy_choice_key_from_validated_value, + ) return await self.flow.async_handle_options_step(user_input, schema, Step.DAILY_ENERGY) diff --git a/custom_components/powercalc/flow_helper/flows/global_configuration.py b/custom_components/powercalc/flow_helper/flows/global_configuration.py index 770ee06..d1ca650 100644 --- a/custom_components/powercalc/flow_helper/flows/global_configuration.py +++ b/custom_components/powercalc/flow_helper/flows/global_configuration.py @@ -3,8 +3,8 @@ from __future__ import annotations from datetime import timedelta from typing import TYPE_CHECKING, Any +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_ENABLED, CONF_SENSORS, UnitOfTime -from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector from homeassistant.helpers.typing import ConfigType import voluptuous as vol @@ -44,7 +44,11 @@ from custom_components.powercalc.const import ( 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 +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 @@ -95,10 +99,16 @@ SCHEMA_GLOBAL_CONFIGURATION_THROTTLING = vol.Schema( 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( + 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( + 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), ), }, @@ -141,7 +151,10 @@ 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: + async def async_step_global_configuration_discovery( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: """Handle the discovery configuration step.""" if user_input is not None: @@ -162,7 +175,10 @@ class GlobalConfigurationFlow: user_input, ) - async def async_step_global_configuration_throttling(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_global_configuration_throttling( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: """Handle the throttling related options.""" if user_input is not None: @@ -184,7 +200,10 @@ class GlobalConfigurationFlow: user_input, ) - async def async_step_global_configuration_energy(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_global_configuration_energy( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: """Handle the global configuration step.""" if user_input is not None: @@ -199,11 +218,18 @@ class GlobalConfigurationFlow: 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/"}}, + 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: + async def async_step_global_configuration_utility_meter( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: """Handle the global configuration step.""" if user_input is not None: @@ -212,7 +238,7 @@ class GlobalConfigurationFlow: 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 + return self.flow.async_create_entry( title="Global Configuration", data=self.flow.global_config, ) @@ -221,7 +247,11 @@ class GlobalConfigurationFlow: 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/"}}, + form_kwarg={ + "description_placeholders": { + "docs_uri": "https://docs.powercalc.nl/configuration/global-configuration/", + }, + }, ), ) @@ -231,7 +261,7 @@ class GlobalConfigurationConfigFlow(GlobalConfigurationFlow): super().__init__(flow) self.flow: PowercalcConfigFlow = flow - async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the global configuration step.""" get_global_powercalc_config(self.flow) await self.flow.async_set_unique_id(ENTRY_GLOBAL_CONFIG_UNIQUE_ID) @@ -272,7 +302,7 @@ class GlobalConfigurationOptionsFlow(GlobalConfigurationFlow): 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: + async def async_step_global_configuration(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the global configuration step.""" if user_input is not None: diff --git a/custom_components/powercalc/flow_helper/flows/group.py b/custom_components/powercalc/flow_helper/flows/group.py index f48c1a3..ec47d49 100644 --- a/custom_components/powercalc/flow_helper/flows/group.py +++ b/custom_components/powercalc/flow_helper/flows/group.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Callable from typing import TYPE_CHECKING, Any from homeassistant.components.sensor import SensorDeviceClass @@ -15,7 +15,6 @@ from homeassistant.const import ( 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 @@ -318,7 +317,7 @@ 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: + async def async_step_assign_groups(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the flow for assigning groups.""" group_entries = get_group_entries(self.flow.hass, GroupType.CUSTOM) if not group_entries: @@ -331,7 +330,7 @@ class GroupFlow: }, ) - async def _validate(user_input: dict[str, Any]) -> dict[str, Any]: + 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: @@ -360,7 +359,7 @@ class GroupConfigFlow(GroupFlow): - name: str | None - selected_sensor_type: str | None - async_set_unique_id(), _abort_if_unique_id_configured() - - handle_form_step(PowercalcFormStep, user_input) -> FlowResult + - handle_form_step(PowercalcFormStep, user_input) -> ConfigFlowResult - async_show_menu(...), fill_schema_defaults(...), - create_group_selector(...), create_schema_group_custom(...) @@ -371,7 +370,7 @@ class GroupConfigFlow(GroupFlow): super().__init__(flow) self.flow: PowercalcConfigFlow = flow - async def async_step_menu_group(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + async def async_step_menu_group(self, _: 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( @@ -387,9 +386,9 @@ class GroupConfigFlow(GroupFlow): 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]: + next_step: Callable[[dict[str, Any]], Step | None] | None = None, + ) -> ConfigFlowResult: + def _validate(ui: dict[str, Any]) -> dict[str, Any]: if group_type == GroupType.CUSTOM: validate_group_input(ui) @@ -412,24 +411,28 @@ class GroupConfigFlow(GroupFlow): user_input, ) - async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: schema = SCHEMA_GROUP.extend(create_schema_group_custom(self.flow.hass).schema) 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: + async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 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: + async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 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: + async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: 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 + 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, @@ -438,7 +441,10 @@ class GroupConfigFlow(GroupFlow): next_step=_next, ) - async def async_step_group_tracked_untracked_auto(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_group_tracked_untracked_auto( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: schema = await create_schema_tracked_untracked_auto(self.flow.hass) return await self.flow.handle_form_step( PowercalcFormStep( @@ -449,7 +455,10 @@ class GroupConfigFlow(GroupFlow): user_input, ) - async def async_step_group_tracked_untracked_manual(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_group_tracked_untracked_manual( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: schema = await create_schema_group_tracked_untracked_manual(self.flow.hass, user_input) return await self.flow.handle_form_step( PowercalcFormStep( @@ -468,21 +477,23 @@ class GroupOptionsFlow(GroupFlow): super().__init__(flow) self.flow: PowercalcOptionsFlow = flow - async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_group_custom(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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 + 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: + async def async_step_group_domain(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_group_subtract(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_group_tracked_untracked(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the group options flow.""" schema = SCHEMA_GROUP_TRACKED_UNTRACKED if self.flow.sensor_config.get(CONF_GROUP_TRACKED_AUTO, True): diff --git a/custom_components/powercalc/flow_helper/flows/library.py b/custom_components/powercalc/flow_helper/flows/library.py index 8f11a9d..72b2371 100644 --- a/custom_components/powercalc/flow_helper/flows/library.py +++ b/custom_components/powercalc/flow_helper/flows/library.py @@ -1,9 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from homeassistant.config_entries import ConfigFlowResult -from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector, translation import voluptuous as vol @@ -31,10 +30,23 @@ from custom_components.powercalc.discovery import ( ) 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.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 +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 @@ -67,7 +79,7 @@ class LibraryFlow: async def async_step_manufacturer( self, user_input: dict[str, Any] | None = None, - ) -> FlowResult: + ) -> ConfigFlowResult: """Ask the user to select the manufacturer.""" async def _create_schema() -> vol.Schema: @@ -82,7 +94,10 @@ class LibraryFlow: ] return vol.Schema( { - vol.Required(CONF_MANUFACTURER, default=self.flow.sensor_config.get(CONF_MANUFACTURER)): selector.SelectSelector( + vol.Required( + CONF_MANUFACTURER, + default=self.flow.sensor_config.get(CONF_MANUFACTURER), + ): selector.SelectSelector( selector.SelectSelectorConfig( options=manufacturers, mode=selector.SelectSelectorMode.DROPDOWN, @@ -104,7 +119,7 @@ class LibraryFlow: async def async_step_model( self, user_input: dict[str, Any] | None = None, - ) -> FlowResult: + ) -> ConfigFlowResult: """Ask the user to select the model.""" def _build_model_label(model_id: str, model_name: str) -> str: @@ -139,10 +154,18 @@ class LibraryFlow: self._get_library_discovery_by(), ) ] - model = self.flow.selected_profile.model if self.flow.selected_profile else self.flow.sensor_config.get(CONF_MODEL) + 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( + vol.Required( + CONF_MODEL, + description={"suggested_value": model}, + default=model, + ): selector.SelectSelector( selector.SelectSelectorConfig( options=models, mode=selector.SelectSelectorMode.DROPDOWN, @@ -162,26 +185,29 @@ class LibraryFlow: user_input, ) - async def async_step_post_library( - self, - user_input: dict[str, Any] | None = None, - ) -> FlowResult: + async def async_step_post_library(self, _: dict[str, Any] | None = None) -> ConfigFlowResult: """ 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 + return self.flow.async_abort(reason="model_not_supported") # 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: + 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: + 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 ( @@ -191,21 +217,26 @@ class LibraryFlow: ): 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.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[no-any-return] 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 + return await self.flow.flow_handlers[FlowType.VIRTUAL_POWER].async_step_linear() # type: ignore[no-any-return] - 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 + 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[no-any-return] - return await self.flow.flow_handlers[FlowType.GROUP].async_step_assign_groups() # type:ignore + return await self.flow.flow_handlers[FlowType.GROUP].async_step_assign_groups() # type: ignore[no-any-return] - async def async_step_library_custom_fields(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_library_custom_fields(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the flow for custom fields.""" - async def _process_user_input(user_input: dict[str, Any]) -> dict[str, Any]: + def _process_user_input(user_input: dict[str, Any]) -> dict[str, Any]: return {CONF_VARIABLES: user_input} form_kwarg: dict[str, Any] | None = None @@ -234,11 +265,11 @@ class LibraryFlow: async def async_step_sub_profile( self, user_input: dict[str, Any] | None = None, - ) -> FlowResult: + ) -> ConfigFlowResult: """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]: + 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) @@ -254,7 +285,11 @@ class LibraryFlow: 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 + 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( @@ -275,16 +310,16 @@ class LibraryFlow: async def async_step_sub_profile_per_device( self, user_input: dict[str, Any] | None = None, - ) -> FlowResult: + ) -> ConfigFlowResult: return await self.async_step_sub_profile(user_input) - async def async_step_smart_switch(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_smart_switch(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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]: + 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, @@ -303,7 +338,7 @@ class LibraryFlow: user_input, ) - async def async_step_availability_entity(self, user_input: dict[str, Any] | None = None) -> FlowResult | None: + async def async_step_availability_entity(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult | None: """Handle the flow for availability entity.""" # Auto-resolve availability entity from profile placeholders auto_entity = self._resolve_availability_entity() @@ -377,7 +412,7 @@ class LibraryConfigFlow(LibraryFlow): async def async_step_library_multi_profile( self, user_input: dict[str, Any] | None = None, - ) -> FlowResult | ConfigFlowResult: + ) -> 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 @@ -426,7 +461,7 @@ class LibraryConfigFlow(LibraryFlow): async def async_step_library( self, user_input: dict[str, Any] | None = None, - ) -> FlowResult: + ) -> ConfigFlowResult: """Try to autodiscover manufacturer/model first. Ask the user to confirm this or forward to manual library selection. """ @@ -439,7 +474,7 @@ class LibraryConfigFlow(LibraryFlow): return self._show_autodiscovered_profile_form() - async def _handle_library_confirmation(self, user_input: dict[str, Any]) -> FlowResult: + async def _handle_library_confirmation(self, user_input: dict[str, Any]) -> ConfigFlowResult: """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() @@ -454,27 +489,31 @@ class LibraryConfigFlow(LibraryFlow): 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: + 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) + 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: + def _show_autodiscovered_profile_form(self) -> ConfigFlowResult: """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, - ), + return 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]: @@ -499,9 +538,19 @@ class LibraryConfigFlow(LibraryFlow): 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}" + 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 + ): + label = translations.get(f"component.{DOMAIN}.common.source_device") + return f"{label}: {self.flow.source_entity.device_entry.name}" return f"{translations.get(f'component.{DOMAIN}.common.source_entity')}: {self.flow.source_entity_id}" @@ -537,7 +586,7 @@ class LibraryOptionsFlow(LibraryFlow): super().__init__(flow) self.flow: PowercalcOptionsFlow = flow - async def async_step_library_options(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_library_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the basic options flow.""" self.flow.is_library_flow = True self.flow.selected_sub_profile = self.flow.selected_profile.sub_profile # type: ignore diff --git a/custom_components/powercalc/flow_helper/flows/real_power.py b/custom_components/powercalc/flow_helper/flows/real_power.py index 6fdc7ea..9b75da4 100644 --- a/custom_components/powercalc/flow_helper/flows/real_power.py +++ b/custom_components/powercalc/flow_helper/flows/real_power.py @@ -5,8 +5,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_DEVICE, CONF_ENTITY_ID, CONF_NAME -from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector import voluptuous as vol @@ -39,7 +39,7 @@ 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: + async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the flow for real power sensor""" self.flow.selected_sensor_type = SensorType.REAL_POWER @@ -57,6 +57,6 @@ 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: + async def async_step_real_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the real power options flow.""" return await self.flow.async_handle_options_step(user_input, SCHEMA_REAL_POWER_OPTIONS, Step.REAL_POWER) diff --git a/custom_components/powercalc/flow_helper/flows/virtual_power.py b/custom_components/powercalc/flow_helper/flows/virtual_power.py index 45801d8..cdff5ec 100644 --- a/custom_components/powercalc/flow_helper/flows/virtual_power.py +++ b/custom_components/powercalc/flow_helper/flows/virtual_power.py @@ -4,8 +4,8 @@ from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any +from homeassistant.config_entries import ConfigFlowResult 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 @@ -17,6 +17,8 @@ from custom_components.powercalc.const import ( CONF_CALIBRATE, CONF_CREATE_ENERGY_SENSOR, CONF_CREATE_UTILITY_METERS, + CONF_FIXED, + CONF_FIXED_VALUE, CONF_GAMMA_CURVE, CONF_IGNORE_UNAVAILABLE_STATE, CONF_MAX_POWER, @@ -24,6 +26,7 @@ from custom_components.powercalc.const import ( CONF_MODE, CONF_MULTIPLY_FACTOR, CONF_MULTIPLY_FACTOR_STANDBY, + CONF_PLAYBOOK_ID, CONF_PLAYBOOKS, CONF_POWER, CONF_POWER_OFF, @@ -34,13 +37,25 @@ from custom_components.powercalc.const import ( CONF_STATE_TRIGGER, CONF_STATES_POWER, CONF_UNAVAILABLE_POWER, + CONF_VALUE, 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.common import ( + FlowType, + PowercalcFormStep, + Step, + fill_schema_defaults, + unwrap_choose_selector, + wrap_choose_selector, +) from custom_components.powercalc.flow_helper.flows.global_configuration import get_global_powercalc_config -from custom_components.powercalc.flow_helper.flows.library import SCHEMA_POWER_OPTIONS_LIBRARY, SCHEMA_POWER_SMART_SWITCH +from custom_components.powercalc.flow_helper.flows.library import ( + SCHEMA_POWER_OPTIONS_LIBRARY, + SCHEMA_POWER_SMART_SWITCH, +) +from custom_components.powercalc.flow_helper.profile_preview import PREVIEW_NAME from custom_components.powercalc.flow_helper.schema import ( SCHEMA_ENERGY_SENSOR_TOGGLE, SCHEMA_SENSOR_ENERGY_OPTIONS, @@ -76,23 +91,125 @@ SCHEMA_POWER_OPTIONS = vol.Schema( }, ) +STATES_POWER_SELECTOR = selector.ObjectSelector( + selector.ObjectSelectorConfig( + fields={ + CONF_STATE: {"required": True, "selector": {"text": None}}, + CONF_POWER: {"required": True, "selector": {"number": {"mode": "box", "step": "any"}}}, + }, + multiple=True, + label_field=CONF_STATE, + description_field=CONF_POWER, + ), +) + +FIXED_CHOICE_SELECTORS: dict[str, selector.ChooseSelectorChoiceConfig] = { + CONF_POWER: {"selector": {"number": {"mode": "box", "step": "any"}}}, + CONF_POWER_TEMPLATE: {"selector": {"template": {}}}, + CONF_STATES_POWER: {"selector": STATES_POWER_SELECTOR.serialize()["selector"]}, +} + + +def order_choices_for_default( + choices: dict[str, selector.ChooseSelectorChoiceConfig], + default_choice: str | None, +) -> dict[str, selector.ChooseSelectorChoiceConfig]: + """Put the default choice first because HA initializes choose selectors from the first choice.""" + if default_choice not in choices: + return choices + return { + default_choice: choices[default_choice], + **{choice: config for choice, config in choices.items() if choice != default_choice}, + } + + +def find_present_choice(form_data: dict[str, Any], choices: dict[str, list[str] | str]) -> str | None: + """Find the first choice that has matching config data.""" + for choice_id, mapping in choices.items(): + keys = [mapping] if isinstance(mapping, str) else mapping + if any(key in form_data for key in keys): + return choice_id + return None + + SCHEMA_POWER_FIXED = vol.Schema( { - vol.Optional(CONF_POWER): vol.Coerce(float), - vol.Optional(CONF_POWER_TEMPLATE): selector.TemplateSelector(), - vol.Optional(CONF_STATES_POWER): selector.ObjectSelector(), + vol.Required(CONF_FIXED_VALUE): selector.ChooseSelector( + selector.ChooseSelectorConfig( + choices=FIXED_CHOICE_SELECTORS, + translation_key=CONF_FIXED_VALUE, + ), + ), }, ) 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(), + vol.Optional(CONF_MIN_POWER): selector.NumberSelector( + selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX, step="any"), + ), + vol.Optional(CONF_MAX_POWER): selector.NumberSelector( + selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX, step="any"), + ), + vol.Optional(CONF_GAMMA_CURVE): selector.NumberSelector( + selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX, step="any"), + ), + vol.Optional(CONF_CALIBRATE): selector.ObjectSelector( + selector.ObjectSelectorConfig( + fields={ + CONF_VALUE: {"required": True, "selector": {"number": {"mode": "box", "step": 1}}}, + CONF_POWER: {"required": True, "selector": {"number": {"mode": "box", "step": "any"}}}, + }, + multiple=True, + label_field=CONF_VALUE, + description_field=CONF_POWER, + ), + ), }, ) +FIXED_CHOICES: dict[str, list[str] | str] = { + CONF_STATES_POWER: CONF_STATES_POWER, + CONF_POWER_TEMPLATE: CONF_POWER_TEMPLATE, + CONF_POWER: CONF_POWER, +} + + +def fixed_choice_key_from_validated_value(value: object) -> str: + """Infer the fixed strategy config key from a validated ChooseSelector value.""" + if isinstance(value, list): + return CONF_STATES_POWER + if isinstance(value, str): + return CONF_POWER_TEMPLATE + return CONF_POWER + + +def unwrap_strategy_user_input(strategy: CalculationStrategy, user_input: dict[str, Any]) -> dict[str, Any]: + """Unwrap ChooseSelector wrappers and normalize list/dict shapes for strategy user input.""" + if strategy == CalculationStrategy.FIXED: + unwrap_choose_selector(user_input, CONF_FIXED_VALUE, fixed_choice_key_from_validated_value) + if CONF_STATE_TRIGGER in user_input and isinstance(user_input[CONF_STATE_TRIGGER], list): + user_input[CONF_STATE_TRIGGER] = { + item[CONF_STATE]: item[CONF_PLAYBOOK_ID] for item in user_input[CONF_STATE_TRIGGER] + } + return user_input + + +def wrap_strategy_form_data(strategy: CalculationStrategy, form_data: dict[str, Any]) -> dict[str, Any]: + """Wrap flat strategy config back into ChooseSelector form structure for display.""" + if strategy == CalculationStrategy.FIXED: + form_data = wrap_choose_selector(form_data, CONF_FIXED_VALUE, FIXED_CHOICES, raw_value=True) + if CONF_STATE_TRIGGER in form_data and isinstance(form_data[CONF_STATE_TRIGGER], dict): + form_data = { + **form_data, + CONF_STATE_TRIGGER: [ + {CONF_STATE: state, CONF_PLAYBOOK_ID: playbook_id} + for state, playbook_id in form_data[CONF_STATE_TRIGGER].items() + ], + } + return form_data + + SCHEMA_POWER_MULTI_SWITCH_MANUAL = vol.Schema( { vol.Required(CONF_POWER): vol.Coerce(float), @@ -127,6 +244,8 @@ STRATEGY_STEP_MAPPING: dict[CalculationStrategy, Step] = { CalculationStrategy.WLED: Step.WLED, } +STRATEGIES_WITHOUT_PREVIEW = {CalculationStrategy.PLAYBOOK, CalculationStrategy.MULTI_SWITCH} + class VirtualPowerFlow: def __init__(self, flow: PowercalcCommonFlow) -> None: @@ -139,13 +258,13 @@ class VirtualPowerFlow: 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 await getattr(self, create_schema_func)() # type: ignore[no-any-return] 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 + return SCHEMA_POWER_LINEAR.extend( # type: ignore[no-any-return] { vol.Optional(CONF_ATTRIBUTE): selector.AttributeSelector( selector.AttributeSelectorConfig( @@ -156,6 +275,21 @@ class VirtualPowerFlow: }, ) + async def create_schema_fixed(self) -> vol.Schema: + """Create the config schema for fixed strategy.""" + fixed_config = self.flow.sensor_config.get(CONF_FIXED, {}) + default_choice = find_present_choice(fixed_config, FIXED_CHOICES) if isinstance(fixed_config, dict) else None + return vol.Schema( + { + vol.Required(CONF_FIXED_VALUE): selector.ChooseSelector( + selector.ChooseSelectorConfig( + choices=order_choices_for_default(FIXED_CHOICE_SELECTORS, default_choice), + translation_key=CONF_FIXED_VALUE, + ), + ), + }, + ) + async def create_schema_multi_switch(self) -> vol.Schema: """Create the config schema for multi switch strategy.""" @@ -180,31 +314,57 @@ class VirtualPowerFlow: 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()] + + def _find_playbook_files() -> list[str]: + base_path = Path(self.flow.hass.config.path("powercalc/playbooks")) + return [str(p.relative_to(base_path)) for p in base_path.rglob("*") if p.is_file()] + + playbook_files = await self.flow.hass.async_add_executor_job(_find_playbook_files) + + state_trigger_state_selector: dict[str, Any] = {"text": None} + if self.flow.source_entity_id and self.flow.source_entity_id != DUMMY_ENTITY_ID: + state_trigger_state_selector = {"state": {"entity_id": self.flow.source_entity_id}} return vol.Schema( { vol.Optional(CONF_PLAYBOOKS): selector.ObjectSelector( - { - "multiple": True, - "description_field": CONF_PATH, - "label_field": CONF_ID, - "fields": { + selector.ObjectSelectorConfig( + fields={ CONF_ID: { "required": True, "selector": {"text": None}, }, CONF_PATH: { "required": True, - "selector": {"select": {"options": playbook_files, "mode": "dropdown", "custom_value": True}}, + "selector": { + "select": {"options": playbook_files, "mode": "dropdown", "custom_value": True}, + }, }, }, - }, + multiple=True, + description_field=CONF_PATH, + label_field=CONF_ID, + ), ), vol.Optional(CONF_REPEAT): selector.BooleanSelector(), vol.Optional(CONF_AUTOSTART): selector.TextSelector(), - vol.Optional(CONF_STATE_TRIGGER): selector.ObjectSelector(), + vol.Optional(CONF_STATE_TRIGGER): selector.ObjectSelector( + selector.ObjectSelectorConfig( + fields={ + CONF_STATE: { + "required": True, + "selector": state_trigger_state_selector, + }, + CONF_PLAYBOOK_ID: { + "required": True, + "selector": {"text": None}, + }, + }, + multiple=True, + description_field=CONF_PLAYBOOK_ID, + label_field=CONF_STATE, + ), + ), }, ) @@ -213,25 +373,25 @@ class VirtualPowerFlow: strategy: CalculationStrategy, user_input: dict[str, Any] | None = None, validate: Callable[[dict[str, Any]], None] | None = None, - ) -> FlowResult: + ) -> ConfigFlowResult: self.flow.strategy = strategy async def _validate(user_input: dict[str, Any]) -> dict[str, Any]: + user_input = unwrap_strategy_user_input(strategy, user_input) 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/", - } + description_placeholders = { + "docs_uri": f"https://docs.powercalc.nl/strategies/{strategy.value.replace('_', '-')}/", + } + + form_kwarg: dict[str, Any] = {"description_placeholders": description_placeholders} + if strategy not in STRATEGIES_WITHOUT_PREVIEW: + form_kwarg["preview"] = PREVIEW_NAME return await self.flow.handle_form_step( PowercalcFormStep( @@ -239,12 +399,12 @@ class VirtualPowerFlow: schema=schema, next_step=Step.ASSIGN_GROUPS, validate_user_input=_validate, - form_kwarg={"description_placeholders": description_placeholders}, + form_kwarg=form_kwarg, ), user_input, ) - async def async_step_power_advanced(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_power_advanced(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the flow for advanced options.""" if self.flow.is_options_flow: @@ -302,9 +462,9 @@ class VirtualPowerConfigFlow(VirtualPowerFlow): options_schema, get_global_powercalc_config(self.flow), ) - return schema.extend(power_options.schema) # type: ignore + return schema.extend(power_options.schema) # type: ignore[no-any-return] - async def async_step_virtual_power(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_virtual_power(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the flow for virtual power sensor.""" errors: dict[str, str] = {} @@ -313,12 +473,16 @@ class VirtualPowerConfigFlow(VirtualPowerFlow): 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: + 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 = create_source_entity( self.flow.source_entity_id, self.flow.hass, ) @@ -329,33 +493,30 @@ class VirtualPowerConfigFlow(VirtualPowerFlow): return await self.forward_to_strategy_step(selected_strategy) - return self.flow.async_show_form( # type: ignore + return self.flow.async_show_form( 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: + async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the flow for playbook sensor.""" def _validate(user_input: dict[str, Any]) -> None: @@ -364,13 +525,13 @@ class VirtualPowerConfigFlow(VirtualPowerFlow): return await self.handle_strategy_step(CalculationStrategy.PLAYBOOK, user_input, _validate) - async def forward_to_strategy_step(self, strategy: CalculationStrategy) -> FlowResult: + async def forward_to_strategy_step(self, strategy: CalculationStrategy) -> ConfigFlowResult: """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 + return await self.flow.flow_handlers[FlowType.LIBRARY].async_step_library() # type: ignore[no-any-return] method = getattr(self.flow, f"async_step_{step}") - return await method() # type: ignore + return await method() # type: ignore[no-any-return] class VirtualPowerOptionsFlow(VirtualPowerFlow): @@ -383,40 +544,52 @@ class VirtualPowerOptionsFlow(VirtualPowerFlow): user_input: dict[str, Any], ) -> dict[str, Any]: """Build the config dict needed for the configured strategy.""" + if self.flow.strategy: + user_input = unwrap_strategy_user_input(self.flow.strategy, dict(user_input)) strategy_schema = await self.create_strategy_schema() strategy_options: dict[str, Any] = {} + flat_keys: set[str] = set() for key in strategy_schema.schema: + base_key = key.schema if isinstance(key, vol.Marker) else key + flat_keys.add(str(base_key)) + # The wrapper-key flat values land in user_input after unwrap; collect anything + # that matches a known strategy config key. + candidate_keys = flat_keys | { + CONF_POWER, + CONF_POWER_TEMPLATE, + CONF_STATES_POWER, + CONF_MIN_POWER, + CONF_MAX_POWER, + CONF_GAMMA_CURVE, + CONF_CALIBRATE, + } + for key in candidate_keys: 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() - ] + strategy_options[key] = user_input[key] return strategy_options - async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> FlowResult: + async def async_step_fixed(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_linear(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_wled(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_multi_switch(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_step_playbook(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """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: + async def async_handle_strategy_options_step(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Handle the option processing for the selected strategy.""" step = STRATEGY_STEP_MAPPING.get(self.flow.strategy or CalculationStrategy.FIXED, Step.FIXED) @@ -429,8 +602,13 @@ class VirtualPowerOptionsFlow(VirtualPowerFlow): **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]} + if self.flow.strategy: + merged_options = wrap_strategy_form_data(self.flow.strategy, merged_options) schema = fill_schema_defaults(schema, merged_options) - return await self.flow.async_handle_options_step(user_input, schema, step) + form_kwarg = {"preview": PREVIEW_NAME} if self.flow.strategy not in STRATEGIES_WITHOUT_PREVIEW else None + return await self.flow.async_handle_options_step( + user_input, + schema, + step, + form_kwarg=form_kwarg, + ) diff --git a/custom_components/powercalc/flow_helper/profile_preview.py b/custom_components/powercalc/flow_helper/profile_preview.py new file mode 100644 index 0000000..e22dc0d --- /dev/null +++ b/custom_components/powercalc/flow_helper/profile_preview.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Protocol, cast + +from homeassistant.components import websocket_api +from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import UnknownFlow +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType +import voluptuous as vol + +from custom_components.powercalc.common import SourceEntity +from custom_components.powercalc.const import CONF_FIXED_VALUE, CalculationStrategy +from custom_components.powercalc.errors import StrategyConfigurationError, UnsupportedStrategyError +from custom_components.powercalc.flow_helper.common import unwrap_choose_selector +from custom_components.powercalc.power_profile.power_profile import PowerProfile +from custom_components.powercalc.strategy.factory import PowerCalculatorStrategyFactory +from custom_components.powercalc.strategy.selector import detect_calculation_strategy + +PREVIEW_NAME = "powercalc" +PREVIEW_FRIENDLY_NAME = "Preview power" +PREVIEW_ICON = "mdi:flash" + + +class PreviewFlowProtocol(Protocol): + sensor_config: ConfigType + selected_profile: PowerProfile | None + source_entity: SourceEntity | None + + +async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up the Powercalc preview websocket command.""" + websocket_api.async_register_command(hass, ws_start_preview) + + +@websocket_api.websocket_command( + { + vol.Required("type"): f"{PREVIEW_NAME}/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + }, +) +@websocket_api.async_response +async def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a live Powercalc strategy preview.""" + flow = _get_flow_handler(hass, msg) + flow_status = _get_flow_status(hass, msg) + errors = _validate_user_input(flow_status.get("data_schema"), msg["user_input"]) + if errors: + connection.send_message( + { + "id": msg["id"], + "type": websocket_api.TYPE_RESULT, + "success": False, + "error": {"code": "invalid_user_input", "message": errors}, + }, + ) + return + + source_entity = flow.source_entity + if source_entity is None: + raise HomeAssistantError("No source entity available for Powercalc preview") + + preview = await build_profile_preview( + hass, + _build_preview_sensor_config(flow, flow_status["step_id"], msg["user_input"]), + source_entity, + flow.selected_profile, + ) + + connection.send_result(msg["id"]) + connection.send_message( + websocket_api.event_message( + msg["id"], + { + "attributes": preview["attributes"], + "state": preview["state"], + }, + ), + ) + connection.subscriptions[msg["id"]] = lambda: None + + +def _get_flow_handler(hass: HomeAssistant, msg: dict[str, Any]) -> PreviewFlowProtocol: + manager = hass.config_entries.flow if msg["flow_type"] == "config_flow" else hass.config_entries.options + try: + return cast(PreviewFlowProtocol, manager._progress[msg["flow_id"]]) # noqa: SLF001 + except KeyError as err: + raise UnknownFlow from err + + +def _get_flow_status(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str, Any]: + manager = hass.config_entries.flow if msg["flow_type"] == "config_flow" else hass.config_entries.options + return cast(dict[str, Any], manager.async_get(msg["flow_id"])) + + +def _validate_user_input(schema: vol.Schema | None, user_input: dict[str, Any]) -> dict[str, str]: + if schema is None: + return {} + + errors: dict[str, str] = {} + key: vol.Marker + for key, validator in schema.schema.items(): + if key.schema not in user_input: + continue + try: + validator(user_input[key.schema]) + except vol.Invalid as ex: + errors[str(key.schema)] = str(ex.msg) + return errors + + +def _build_preview_sensor_config(flow: PreviewFlowProtocol, step_id: str, user_input: dict[str, Any]) -> ConfigType: + sensor_config = dict(flow.sensor_config) + try: + strategy = CalculationStrategy(step_id) + except ValueError: + return sensor_config + + sensor_config[strategy] = _unwrap_preview_strategy_input(strategy, user_input) + return sensor_config + + +def _unwrap_preview_strategy_input(strategy: CalculationStrategy, user_input: dict[str, Any]) -> dict[str, Any]: + """Unwrap form-only selector wrappers before building a preview strategy config.""" + unwrapped = dict(user_input) + if strategy == CalculationStrategy.FIXED: + unwrap_choose_selector(unwrapped, CONF_FIXED_VALUE) + return unwrapped + + +async def build_profile_preview( + hass: HomeAssistant, + sensor_config: ConfigType, + source_entity: SourceEntity, + power_profile: PowerProfile | None, +) -> dict[str, Any]: + """Build an entity-like preview containing only current calculated power.""" + current_power = await _calculate_current_power(hass, sensor_config, source_entity, power_profile) + return { + "attributes": { + ATTR_FRIENDLY_NAME: PREVIEW_FRIENDLY_NAME, + ATTR_ICON: PREVIEW_ICON, + }, + "state": _format_preview_state(current_power), + } + + +async def _calculate_current_power( + hass: HomeAssistant, + sensor_config: ConfigType, + source_entity: SourceEntity, + power_profile: PowerProfile | None, +) -> Decimal | None: + current_state = hass.states.get(source_entity.entity_id) + if current_state is None: + return None + + try: + cv.template_complex(sensor_config) + except vol.Invalid: + return None + + strategy = detect_calculation_strategy(sensor_config, power_profile) + try: + calculation_strategy = await PowerCalculatorStrategyFactory(hass).create( + sensor_config, + strategy, + power_profile, + source_entity, + ) + except (StrategyConfigurationError, UnsupportedStrategyError): + return None + + try: + return await calculation_strategy.calculate(current_state) + except HomeAssistantError: + return None + + +def _format_preview_state(power: Decimal | None) -> str: + if power is None: + return "unavailable" + return f"{_format_power(power)} W" + + +def _format_power(value: Decimal | float | str | None) -> str: + if value is None: + return "0" + decimal_value = Decimal(str(value)) + return f"{decimal_value:.2f}".rstrip("0").rstrip(".") diff --git a/custom_components/powercalc/group_include/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/group_include/__pycache__/__init__.cpython-314.pyc index ef4ed16..e116fdd 100644 Binary files a/custom_components/powercalc/group_include/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/group_include/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/group_include/__pycache__/filter.cpython-314.pyc b/custom_components/powercalc/group_include/__pycache__/filter.cpython-314.pyc index c7b2bdb..ba33610 100644 Binary files a/custom_components/powercalc/group_include/__pycache__/filter.cpython-314.pyc and b/custom_components/powercalc/group_include/__pycache__/filter.cpython-314.pyc differ diff --git a/custom_components/powercalc/group_include/__pycache__/include.cpython-314.pyc b/custom_components/powercalc/group_include/__pycache__/include.cpython-314.pyc index 5d81130..c42ef04 100644 Binary files a/custom_components/powercalc/group_include/__pycache__/include.cpython-314.pyc and b/custom_components/powercalc/group_include/__pycache__/include.cpython-314.pyc differ diff --git a/custom_components/powercalc/group_include/filter.py b/custom_components/powercalc/group_include/filter.py index ba70c81..31cf75c 100644 --- a/custom_components/powercalc/group_include/filter.py +++ b/custom_components/powercalc/group_include/filter.py @@ -94,7 +94,7 @@ def create_filter( 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_TEMPLATE: lambda: TemplateFilter(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 @@ -104,7 +104,7 @@ def create_filter( return filter_mapping.get(filter_type, lambda: NullFilter())() -async def get_filtered_entity_list( +def get_filtered_entity_list( hass: HomeAssistant, entity_filter: EntityFilter, ) -> list[entity_registry.RegistryEntry]: @@ -133,7 +133,11 @@ class GroupFilter(EntityFilter): 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) + 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] @@ -227,8 +231,7 @@ class WildcardFilter(EntityFilter): class TemplateFilter(EntityFilter): - def __init__(self, hass: HomeAssistant, template: Template) -> None: - template.hass = hass + def __init__(self, template: Template) -> None: self.entity_ids = template.async_render() def is_valid(self, entity: RegistryEntry) -> bool: @@ -311,7 +314,9 @@ class AreaFilter(EntityFilter): ) 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)]) + 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 @@ -350,7 +355,9 @@ class FloorFilter(EntityFilter): 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)]) + 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 diff --git a/custom_components/powercalc/group_include/include.py b/custom_components/powercalc/group_include/include.py index 39d8b93..cc732a1 100644 --- a/custom_components/powercalc/group_include/include.py +++ b/custom_components/powercalc/group_include/include.py @@ -5,6 +5,7 @@ from homeassistant.components import sensor from homeassistant.components.sensor import SensorDeviceClass from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_registry import RegistryEntry from custom_components.powercalc.common import create_source_entity from custom_components.powercalc.const import ( @@ -52,7 +53,7 @@ async def find_entities( resolved_entities: list[Entity] = [] discoverable_entities: list[str] = [] - source_entities = await get_filtered_entity_list(hass, _build_filter(entity_filter)) + source_entities = 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]) @@ -70,29 +71,15 @@ async def find_entities( 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: + if _should_skip_source_entity(source_entity, include_non_powercalc): 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): + real_sensor = _create_real_sensor(source_entity) + if real_sensor: + resolved_entities.append(real_sensor) + continue + + if await _is_discoverable_source_entity(hass, source_entity): discoverable_entities.append(entity_id) if exclude_utility_meters: @@ -105,6 +92,34 @@ async def find_entities( return FindEntitiesResult(resolved_entities, discoverable_entities) +def _should_skip_source_entity(source_entity: RegistryEntry, include_non_powercalc: bool) -> bool: + return source_entity.domain == sensor.DOMAIN and source_entity.platform != DOMAIN and not include_non_powercalc + + +def _create_real_sensor(source_entity: RegistryEntry) -> Entity | None: + if source_entity.domain != sensor.DOMAIN: + return None + + device_class = source_entity.device_class or source_entity.original_device_class + if device_class == SensorDeviceClass.POWER: + return RealPowerSensor(source_entity.entity_id, source_entity.unit_of_measurement) + if device_class == SensorDeviceClass.ENERGY: + return RealEnergySensor(source_entity.entity_id) + return None # pragma: no cover + + +async def _is_discoverable_source_entity(hass: HomeAssistant, source_entity: RegistryEntry) -> bool: + power_profile = await get_power_profile_by_source_entity( + hass, + create_source_entity(source_entity.entity_id, hass), + ) + return bool( + power_profile + and not await power_profile.needs_user_configuration + and power_profile.is_entity_domain_supported(source_entity), + ) + + def _build_filter(entity_filter: EntityFilter | None) -> EntityFilter: base_filter = CompositeFilter( [ @@ -112,7 +127,11 @@ def _build_filter(entity_filter: EntityFilter | None) -> EntityFilter: 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"))), + LambdaFilter( + lambda entity: ( + entity.platform != "tasmota" or not str(entity.entity_id).endswith(("_yesterday", "_today")) + ), + ), ], ) if not entity_filter: diff --git a/custom_components/powercalc/helpers.py b/custom_components/powercalc/helpers.py index 87334dd..f1e3126 100644 --- a/custom_components/powercalc/helpers.py +++ b/custom_components/powercalc/helpers.py @@ -32,7 +32,7 @@ _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: +def evaluate_power(power: Template | Decimal | float) -> Decimal | None: """When power is a template render it.""" if isinstance(power, Decimal): @@ -154,7 +154,10 @@ def collect_placeholders(data: list | str | dict[str, Any]) -> set[str]: return found -def replace_placeholders(data: list | str | dict[str, Any], replacements: dict[str, str]) -> list | str | dict[str, Any]: +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(): @@ -304,7 +307,12 @@ def _get_related_entity_for_device( 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) + _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] diff --git a/custom_components/powercalc/icons.json b/custom_components/powercalc/icons.json index 955473a..ff1be60 100644 --- a/custom_components/powercalc/icons.json +++ b/custom_components/powercalc/icons.json @@ -18,6 +18,9 @@ "get_group_entities": { "service": "mdi:format-list-group" }, + "debug_group": { + "service": "mdi:bug-outline" + }, "increase_daily_energy": { "service": "mdi:numeric" }, diff --git a/custom_components/powercalc/manifest.json b/custom_components/powercalc/manifest.json index 96b1b58..1b68bcd 100644 --- a/custom_components/powercalc/manifest.json +++ b/custom_components/powercalc/manifest.json @@ -22,5 +22,5 @@ "requirements": [ "numpy>=1.21.1" ], - "version": "v1.20.14" + "version": "v1.21.0" } \ No newline at end of file diff --git a/custom_components/powercalc/migrate.py b/custom_components/powercalc/migrate.py index 41a3031..8777426 100644 --- a/custom_components/powercalc/migrate.py +++ b/custom_components/powercalc/migrate.py @@ -1,7 +1,7 @@ from datetime import timedelta from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ENABLED, CONF_ID, CONF_PATH +from homeassistant.const import CONF_ENABLED, CONF_ID, CONF_PATH, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.issue_registry import async_create_issue @@ -24,6 +24,7 @@ from custom_components.powercalc.const import ( CONF_PLAYBOOK, CONF_PLAYBOOKS, CONF_POWER, + CONF_POWER_SENSOR_CATEGORY, CONF_POWER_TEMPLATE, CONF_SENSOR_TYPE, CONF_STATE, @@ -41,43 +42,74 @@ async def async_migrate_config_entry(hass: HomeAssistant, config_entry: ConfigEn 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) + _migrate_power_template(data) 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) + _migrate_playbook_trigger(data) 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) + _migrate_global_discovery_config(data) 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()] + _migrate_playbooks(data) 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()] + _migrate_states_power(data) - hass.config_entries.async_update_entry(config_entry, data=data, version=7) + if version <= 7: + _migrate_invalid_power_sensor_category(data) + + hass.config_entries.async_update_entry(config_entry, data=data, version=8) + + +def _migrate_power_template(data: dict) -> None: + 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) + + +def _migrate_playbook_trigger(data: dict) -> None: + 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) + + +def _migrate_global_discovery_config(data: dict) -> None: + data[CONF_DISCOVERY] = { + CONF_ENABLED: data.get(CONF_ENABLE_AUTODISCOVERY_DEPRECATED, True), + CONF_EXCLUDE_DEVICE_TYPES: data.get(CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED, []), + CONF_EXCLUDE_SELF_USAGE: data.get(CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED, False), + } + for key in [ + CONF_ENABLE_AUTODISCOVERY_DEPRECATED, + CONF_DISCOVERY_EXCLUDE_DEVICE_TYPES_DEPRECATED, + CONF_DISCOVERY_EXCLUDE_SELF_USAGE_DEPRECATED, + ]: + data.pop(key, None) + + +def _migrate_playbooks(data: dict) -> None: + 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() + ] + + +def _migrate_states_power(data: dict) -> None: + 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() + ] + + +def _migrate_invalid_power_sensor_category(data: dict) -> None: + if data.get(CONF_POWER_SENSOR_CATEGORY) == EntityCategory.CONFIG: + data.pop(CONF_POWER_SENSOR_CATEGORY) async def async_fix_legacy_profile_config_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: @@ -113,7 +145,7 @@ async def async_fix_legacy_profile_config_entry(hass: HomeAssistant, config_entr ) -async def handle_legacy_discovery_config(hass: HomeAssistant, global_config: dict, yaml_config: dict) -> None: +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 = { @@ -150,7 +182,7 @@ async def handle_legacy_discovery_config(hass: HomeAssistant, global_config: dic ) -async def handle_legacy_update_interval_config(hass: HomeAssistant, global_config: dict, yaml_config: dict) -> None: +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 diff --git a/custom_components/powercalc/power_profile/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/power_profile/__pycache__/__init__.cpython-314.pyc index bd8b563..f12bb99 100644 Binary files a/custom_components/powercalc/power_profile/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/power_profile/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/__pycache__/error.cpython-314.pyc b/custom_components/powercalc/power_profile/__pycache__/error.cpython-314.pyc index dd42c86..e96c49f 100644 Binary files a/custom_components/powercalc/power_profile/__pycache__/error.cpython-314.pyc and b/custom_components/powercalc/power_profile/__pycache__/error.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/__pycache__/factory.cpython-314.pyc b/custom_components/powercalc/power_profile/__pycache__/factory.cpython-314.pyc index d97a8e1..bc9f885 100644 Binary files a/custom_components/powercalc/power_profile/__pycache__/factory.cpython-314.pyc and b/custom_components/powercalc/power_profile/__pycache__/factory.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/__pycache__/library.cpython-314.pyc b/custom_components/powercalc/power_profile/__pycache__/library.cpython-314.pyc index 7e31ebf..bae791b 100644 Binary files a/custom_components/powercalc/power_profile/__pycache__/library.cpython-314.pyc and b/custom_components/powercalc/power_profile/__pycache__/library.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/__pycache__/power_profile.cpython-314.pyc b/custom_components/powercalc/power_profile/__pycache__/power_profile.cpython-314.pyc index 4e4ea34..897d01e 100644 Binary files a/custom_components/powercalc/power_profile/__pycache__/power_profile.cpython-314.pyc and b/custom_components/powercalc/power_profile/__pycache__/power_profile.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/__pycache__/sub_profile_selector.cpython-314.pyc b/custom_components/powercalc/power_profile/__pycache__/sub_profile_selector.cpython-314.pyc index 908f006..832c272 100644 Binary files a/custom_components/powercalc/power_profile/__pycache__/sub_profile_selector.cpython-314.pyc and b/custom_components/powercalc/power_profile/__pycache__/sub_profile_selector.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/library.py b/custom_components/powercalc/power_profile/library.py index c7aec88..f144628 100644 --- a/custom_components/powercalc/power_profile/library.py +++ b/custom_components/powercalc/power_profile/library.py @@ -141,7 +141,13 @@ class ProfileLibrary: 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) + 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) @@ -163,7 +169,11 @@ class ProfileLibrary: 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) + 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) @@ -202,7 +212,12 @@ class ProfileLibrary: 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]: + 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: @@ -216,7 +231,9 @@ class ProfileLibrary: source_entity=source_entity, ) if not related_entity: - raise LibraryError(build_related_entity_placeholder_not_found_message(placeholder, source_entity.entity_id)) + raise LibraryError( + build_related_entity_placeholder_not_found_message(placeholder, source_entity.entity_id), + ) variables[placeholder] = related_entity return variables @@ -286,7 +303,9 @@ class ProfileLibrary: 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 + 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") diff --git a/custom_components/powercalc/power_profile/loader/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/power_profile/loader/__pycache__/__init__.cpython-314.pyc index a640e4b..d697a6e 100644 Binary files a/custom_components/powercalc/power_profile/loader/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/power_profile/loader/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/loader/__pycache__/composite.cpython-314.pyc b/custom_components/powercalc/power_profile/loader/__pycache__/composite.cpython-314.pyc index bbf9168..a320c28 100644 Binary files a/custom_components/powercalc/power_profile/loader/__pycache__/composite.cpython-314.pyc and b/custom_components/powercalc/power_profile/loader/__pycache__/composite.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/loader/__pycache__/local.cpython-314.pyc b/custom_components/powercalc/power_profile/loader/__pycache__/local.cpython-314.pyc index 984768d..cc3e15d 100644 Binary files a/custom_components/powercalc/power_profile/loader/__pycache__/local.cpython-314.pyc and b/custom_components/powercalc/power_profile/loader/__pycache__/local.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/loader/__pycache__/protocol.cpython-314.pyc b/custom_components/powercalc/power_profile/loader/__pycache__/protocol.cpython-314.pyc index ee6cc41..948bcf7 100644 Binary files a/custom_components/powercalc/power_profile/loader/__pycache__/protocol.cpython-314.pyc and b/custom_components/powercalc/power_profile/loader/__pycache__/protocol.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/loader/__pycache__/remote.cpython-314.pyc b/custom_components/powercalc/power_profile/loader/__pycache__/remote.cpython-314.pyc index d50f5fa..9bdd0ea 100644 Binary files a/custom_components/powercalc/power_profile/loader/__pycache__/remote.cpython-314.pyc and b/custom_components/powercalc/power_profile/loader/__pycache__/remote.cpython-314.pyc differ diff --git a/custom_components/powercalc/power_profile/loader/composite.py b/custom_components/powercalc/power_profile/loader/composite.py index 90b8dd6..94e0ecd 100644 --- a/custom_components/powercalc/power_profile/loader/composite.py +++ b/custom_components/powercalc/power_profile/loader/composite.py @@ -11,7 +11,8 @@ class CompositeLoader(Loader): self.loaders = loaders async def initialize(self) -> None: - [await loader.initialize() for loader in self.loaders] # type: ignore[func-returns-value] + for loader in self.loaders: + await loader.initialize() async def get_manufacturer_listing( self, @@ -20,7 +21,11 @@ class CompositeLoader(Loader): ) -> 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)} + 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.""" @@ -42,7 +47,11 @@ class CompositeLoader(Loader): ) -> 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)} + 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: diff --git a/custom_components/powercalc/power_profile/loader/local.py b/custom_components/powercalc/power_profile/loader/local.py index 42286b4..cbfe7da 100644 --- a/custom_components/powercalc/power_profile/loader/local.py +++ b/custom_components/powercalc/power_profile/loader/local.py @@ -1,3 +1,4 @@ +from functools import partial import json import logging import os @@ -101,12 +102,9 @@ class LocalLoader(Loader): _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) + model_path, model_json = await self._hass.async_add_executor_job( + partial(self._load_custom_model, _manufacturer, _model), + ) return model_json, model_path lib_models = self._manufacturer_model_listing.get(_manufacturer) @@ -205,6 +203,17 @@ class LocalLoader(Loader): self._manufacturer_model_listing[manufacturer].update({search_key: profile}) + def _load_custom_model(self, manufacturer: str, model: str) -> tuple[str, dict[str, Any]]: + """Load model.json from a directly configured custom model 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} and model {model} in path {model_json_path}", + ) + + return model_path, self._load_json(model_json_path) + 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: diff --git a/custom_components/powercalc/power_profile/loader/remote.py b/custom_components/powercalc/power_profile/loader/remote.py index 2e4d105..48bfe00 100644 --- a/custom_components/powercalc/power_profile/loader/remote.py +++ b/custom_components/powercalc/power_profile/loader/remote.py @@ -64,7 +64,7 @@ class RemoteLoader(Loader): """Initialize the loader.""" integration = await async_get_integration(self.hass, DOMAIN) - powercalc_version = AwesomeVersion(integration.version) + powercalc_version = AwesomeVersion(str(integration.version)) self.library_contents = await self.load_library_json() self.profile_hashes = await self.hass.async_add_executor_job(self._load_profile_hashes) @@ -138,7 +138,7 @@ class RemoteLoader(Loader): 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. + On success, save it to local storage as a fallback for internet connection issues. """ _LOGGER.debug("Loading library.json from github") @@ -182,7 +182,10 @@ class RemoteLoader(Loader): 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", [])) + if any( + self._model_matches_filters(model, device_types, discovery_by) + for model in manufacturer.get("models", []) + ) } @async_cache @@ -225,7 +228,12 @@ class RemoteLoader(Loader): 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]] + 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: @@ -274,12 +282,19 @@ class RemoteLoader(Loader): 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: + 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) + path_exists = await self.hass.async_add_executor_job(os.path.exists, model_path) if not path_exists: return True @@ -287,7 +302,13 @@ class RemoteLoader(Loader): 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: + 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) @@ -297,12 +318,22 @@ class RemoteLoader(Loader): 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): + path_exists, storage_path_exists = await self.hass.async_add_executor_job( + self._profile_paths_exist, + model_path, + storage_path, + ) + if not path_exists: + if storage_path_exists: 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") + @staticmethod + def _profile_paths_exist(model_path: str, storage_path: str) -> tuple[bool, bool]: + """Check profile paths from the executor.""" + return os.path.exists(model_path), os.path.exists(storage_path) + async def _load_model_json(self, model_path: str) -> dict: """Load the JSON data from the model file.""" @@ -330,7 +361,10 @@ class RemoteLoader(Loader): """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]: + 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 @@ -342,7 +376,9 @@ class RemoteLoader(Loader): _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 + 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) @@ -396,7 +432,7 @@ class RemoteLoader(Loader): return {} with open(path) as f: - return json.load(f) # type: ignore + return json.load(f) # type: ignore[no-any-return] def _write_profile_hashes(self, hashes: dict[str, str]) -> None: """Write profile hashes to local storage""" diff --git a/custom_components/powercalc/power_profile/power_profile.py b/custom_components/powercalc/power_profile/power_profile.py index 1fc8197..15e4d53 100644 --- a/custom_components/powercalc/power_profile/power_profile.py +++ b/custom_components/powercalc/power_profile/power_profile.py @@ -95,7 +95,9 @@ DEVICE_TYPE_DOMAIN: dict[DeviceType, str | set[str]] = { 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})} +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]]: @@ -393,7 +395,8 @@ class PowerProfile: if found_profile is None: raise ModelNotSupportedError( - f"Sub profile not found (manufacturer: {self._manufacturer}, model: {self._model}, sub_profile: {sub_profile})", + f"Sub profile not found (manufacturer: {self._manufacturer}, " + f"model: {self._model}, sub_profile: {sub_profile})", ) self._sub_profile_dir = os.path.join(self._directory, sub_profile) diff --git a/custom_components/powercalc/power_profile/sub_profile_selector.py b/custom_components/powercalc/power_profile/sub_profile_selector.py index a9ed69f..8944afe 100644 --- a/custom_components/powercalc/power_profile/sub_profile_selector.py +++ b/custom_components/powercalc/power_profile/sub_profile_selector.py @@ -2,7 +2,7 @@ from __future__ import annotations from enum import StrEnum import re -from typing import Any, NamedTuple, Protocol +from typing import NamedTuple, Protocol from homeassistant.core import HomeAssistant, State @@ -79,7 +79,13 @@ class SubProfileSelectConfig(NamedTuple): class SubProfileMatcher(Protocol): @classmethod - def from_config(cls, config: dict, **kwargs: Any) -> SubProfileMatcher: # noqa: ANN401 + def from_config( + cls, + config: dict, + *, + hass: HomeAssistant | None = None, + source_entity: SourceEntity | None = None, + ) -> SubProfileMatcher: """Create a matcher from a config dict.""" def match(self, entity_state: State, source_entity: SourceEntity) -> str | None: @@ -114,8 +120,15 @@ class EntityStateMatcher(SubProfileMatcher): 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 from_config( + cls, + config: dict, + *, + hass: HomeAssistant | None = None, + source_entity: SourceEntity | None = None, + ) -> EntityStateMatcher: + assert hass is not None + return cls(hass, source_entity, config["entity_id"], config["map"]) def get_tracking_entities(self) -> list[str]: return [self._entity_id] @@ -134,7 +147,13 @@ class AttributeMatcher(SubProfileMatcher): return self._mapping.get(val) @classmethod - def from_config(cls, config: dict, **kwargs: Any) -> AttributeMatcher: # noqa: ANN401 + def from_config( + cls, + config: dict, + *, + hass: HomeAssistant | None = None, + source_entity: SourceEntity | None = None, + ) -> AttributeMatcher: return cls(config["attribute"], config["map"]) def get_tracking_entities(self) -> list[str]: @@ -153,7 +172,13 @@ class EntityIdMatcher(SubProfileMatcher): return None @classmethod - def from_config(cls, config: dict, **kwargs: Any) -> EntityIdMatcher: # noqa: ANN401 + def from_config( + cls, + config: dict, + *, + hass: HomeAssistant | None = None, + source_entity: SourceEntity | None = None, + ) -> EntityIdMatcher: return cls(config["pattern"], config["profile"]) def get_tracking_entities(self) -> list[str]: @@ -176,7 +201,13 @@ class IntegrationMatcher(SubProfileMatcher): return None @classmethod - def from_config(cls, config: dict, **kwargs: Any) -> IntegrationMatcher: # noqa: ANN401 + def from_config( + cls, + config: dict, + *, + hass: HomeAssistant | None = None, + source_entity: SourceEntity | None = None, + ) -> IntegrationMatcher: return cls(config["integration"], config["profile"]) def get_tracking_entities(self) -> list[str]: @@ -204,7 +235,13 @@ class EntityRegistryMatcher(SubProfileMatcher): return None @classmethod - def from_config(cls, config: dict, **kwargs: Any) -> EntityRegistryMatcher: # noqa: ANN401 + def from_config( + cls, + config: dict, + *, + hass: HomeAssistant | None = None, + source_entity: SourceEntity | None = None, + ) -> EntityRegistryMatcher: return cls(config["property"], config["value"], config["profile"]) def get_tracking_entities(self) -> list[str]: @@ -236,7 +273,13 @@ class ModelIdMatcher(SubProfileMatcher): return None @classmethod - def from_config(cls, config: dict, **kwargs: Any) -> ModelIdMatcher: # noqa: ANN401 + def from_config( + cls, + config: dict, + *, + hass: HomeAssistant | None = None, + source_entity: SourceEntity | None = None, + ) -> ModelIdMatcher: return cls(config["model_id"], config["profile"]) def get_tracking_entities(self) -> list[str]: diff --git a/custom_components/powercalc/repairs.py b/custom_components/powercalc/repairs.py index 981fa41..3e1ebe0 100644 --- a/custom_components/powercalc/repairs.py +++ b/custom_components/powercalc/repairs.py @@ -19,10 +19,7 @@ class SubProfileRepairFlow(RepairsFlow): 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: + async def async_step_init(self, _: dict[str, str] | None = None) -> data_entry_flow.FlowResult: """Handle the first step of a fix flow.""" return await self.async_step_sub_profile() @@ -32,9 +29,9 @@ class SubProfileRepairFlow(RepairsFlow): 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={}) + return self.async_create_entry(title="", data={}) # type: ignore[no-any-return] - source_entity = await create_source_entity(self._config_entry.data[CONF_ENTITY_ID], self._hass) + source_entity = 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) @@ -43,7 +40,7 @@ class SubProfileRepairFlow(RepairsFlow): if remarks: remarks = "\n\n" + remarks - return self.async_show_form( + return self.async_show_form( # type: ignore[no-any-return] step_id="sub_profile", data_schema=sub_profile_schema, description_placeholders={ @@ -57,7 +54,7 @@ class SubProfileRepairFlow(RepairsFlow): async def async_create_fix_flow( hass: HomeAssistant, - issue_id: str, + _: str, data: dict[str, str | int | float | None] | None, ) -> RepairsFlow: """Create flow.""" diff --git a/custom_components/powercalc/select.py b/custom_components/powercalc/select.py index 31a4a5b..7c741da 100644 --- a/custom_components/powercalc/select.py +++ b/custom_components/powercalc/select.py @@ -47,9 +47,9 @@ def delayed_add_entities_handler( async def async_setup_platform( hass: HomeAssistant, - config: ConfigType, + _config: ConfigType, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + _discovery_info: DiscoveryInfoType | None = None, ) -> None: """Setup sensors from YAML config sensor entries.""" key = _key(None) diff --git a/custom_components/powercalc/sensor.py b/custom_components/powercalc/sensor.py index 7978712..c5a4638 100644 --- a/custom_components/powercalc/sensor.py +++ b/custom_components/powercalc/sensor.py @@ -130,6 +130,7 @@ from .const import ( SERVICE_ACTIVATE_PLAYBOOK, SERVICE_CALIBRATE_ENERGY, SERVICE_CALIBRATE_UTILITY_METER, + SERVICE_DEBUG_GROUP, SERVICE_GET_ACTIVE_PLAYBOOK, SERVICE_GET_GROUP_ENTITIES, SERVICE_INCREASE_DAILY_ENERGY, @@ -410,7 +411,7 @@ def _register_entity_id_change_listener( """ @callback - async def _entity_rename_listener(event: Event) -> None: + def _entity_rename_listener(event: Event) -> None: """Handle renaming of the entity""" old_entity_id = event.data["old_entity_id"] new_entity_id = event.data[CONF_ENTITY_ID] @@ -429,12 +430,11 @@ def _register_entity_id_change_listener( """Only dispatch the listener for update events concerning the source entity""" # Breaking change in 2024.4.0, check for Event for versions prior to this - if type(event) is Event: # Intentionally avoid `isinstance` because it's slow and we trust `Event` is not subclassed - event = event.data # pragma: no cover + event_data = event.data if isinstance(event, Event) else event return ( - event["action"] == "update" # type: ignore - and "old_entity_id" in event # type: ignore - and event["old_entity_id"] == source_entity_id # type: ignore + event_data["action"] == "update" + and "old_entity_id" in event_data + and event_data["old_entity_id"] == source_entity_id ) hass.bus.async_listen( @@ -548,6 +548,13 @@ def register_entity_services() -> None: supports_response=SupportsResponse.ONLY, ) + platform.async_register_entity_service( + SERVICE_DEBUG_GROUP, + {}, + "debug_group", + supports_response=SupportsResponse.ONLY, + ) + def convert_config_entry_to_sensor_config(config_entry: ConfigEntry, hass: HomeAssistant) -> ConfigType: # noqa: C901 """Convert the config entry structure to the sensor config used to create the entities.""" @@ -572,7 +579,9 @@ def convert_config_entry_to_sensor_config(config_entry: ConfigEntry, hass: HomeA """Convert on_time dictionary to timedelta.""" on_time = config.get(CONF_ON_TIME) config[CONF_ON_TIME] = ( - timedelta(hours=on_time["hours"], minutes=on_time["minutes"], seconds=on_time["seconds"]) if on_time else timedelta(days=1) + timedelta(hours=on_time["hours"], minutes=on_time["minutes"], seconds=on_time["seconds"]) + if on_time + else timedelta(days=1) ) def process_states_power(states_power: dict | list) -> dict: @@ -582,7 +591,10 @@ def convert_config_entry_to_sensor_config(config_entry: ConfigEntry, hass: HomeA """ if isinstance(states_power, list): states_power = {item[CONF_STATE]: item[CONF_POWER] for item in states_power} - return {key: Template(value, hass) if isinstance(value, str) and "{{" in value else value for key, value in states_power.items()} + return { + key: Template(value, hass) if isinstance(value, str) and "{{" in value else value + for key, value in states_power.items() + } def process_daily_fixed_energy() -> None: """Process daily fixed energy configuration.""" @@ -868,20 +880,15 @@ async def create_individual_sensors( ) -> EntitiesBucket: """Create entities (power, energy, utility meters) which track the appliance.""" - source_entity = await create_source_entity(sensor_config[CONF_ENTITY_ID], hass) + source_entity = create_source_entity(sensor_config[CONF_ENTITY_ID], hass) # For device-based profiles, attach the device entry to the source entity - if source_entity.entity_id == DUMMY_ENTITY_ID and "device" in sensor_config: - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get(sensor_config["device"]) - if device_entry: - source_entity = source_entity._replace(device_entry=device_entry) + source_entity = _attach_configured_device_entry(hass, sensor_config, source_entity) - if (used_unique_ids := hass.data[DOMAIN].get(DATA_USED_UNIQUE_IDS)) is None: - used_unique_ids = hass.data[DOMAIN][DATA_USED_UNIQUE_IDS] = [] # pragma: no cover + used_unique_ids = hass.data[DOMAIN].get(DATA_USED_UNIQUE_IDS, []) try: - await check_entity_not_already_configured( + check_entity_not_already_configured( sensor_config, source_entity, hass, @@ -905,17 +912,17 @@ async def create_individual_sensors( except PowercalcSetupError: return EntitiesBucket() entities_to_add.append(power_sensor) - energy_sensor = await create_energy_sensor_if_needed(hass, sensor_config, power_sensor, source_entity) + energy_sensor = create_energy_sensor_if_needed(hass, sensor_config, power_sensor, source_entity) if energy_sensor: entities_to_add.append(energy_sensor) attach_energy_sensor_to_power_sensor(power_sensor, energy_sensor) if energy_sensor: - entities_to_add.extend(await create_utility_meters(hass, energy_sensor, sensor_config, config_entry)) + entities_to_add.extend(create_utility_meters(hass, energy_sensor, sensor_config, config_entry)) await attach_entities_to_source_device(config_entry, entities_to_add, hass, source_entity) - update_registries(hass, source_entity, entities_to_add, context) + unique_id = sensor_config.get(CONF_UNIQUE_ID) or source_entity.unique_id if unique_id: used_unique_ids.append(unique_id) @@ -925,6 +932,21 @@ async def create_individual_sensors( return EntitiesBucket(new=entities_to_add, existing=[]) +def _attach_configured_device_entry( + hass: HomeAssistant, + sensor_config: dict, + source_entity: SourceEntity, +) -> SourceEntity: + if source_entity.entity_id != DUMMY_ENTITY_ID or "device" not in sensor_config: + return source_entity + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(sensor_config["device"]) + if device_entry: + return source_entity._replace(device_entry=device_entry) + return source_entity + + async def handle_energy_sensor_creation( hass: HomeAssistant, sensor_config: dict, @@ -933,7 +955,7 @@ async def handle_energy_sensor_creation( ) -> EnergySensor | None: """Handle the creation of an energy sensor if needed.""" if CONF_DAILY_FIXED_ENERGY in sensor_config: - energy_sensor = await create_daily_fixed_energy_sensor(hass, sensor_config, source_entity) + energy_sensor = create_daily_fixed_energy_sensor(hass, sensor_config, source_entity) entities_to_add.append(energy_sensor) if source_entity: daily_fixed_power_sensor = await create_daily_fixed_energy_power_sensor(hass, sensor_config, source_entity) @@ -943,15 +965,19 @@ async def handle_energy_sensor_creation( return None -async def create_energy_sensor_if_needed( +def create_energy_sensor_if_needed( hass: HomeAssistant, sensor_config: dict, power_sensor: PowerSensor, source_entity: SourceEntity, ) -> EnergySensor | None: """Create an energy sensor if it is needed.""" - if sensor_config.get(CONF_CREATE_ENERGY_SENSOR) or sensor_config.get(CONF_FORCE_ENERGY_SENSOR_CREATION) or CONF_ENERGY_SENSOR_ID in sensor_config: - return await create_energy_sensor(hass, sensor_config, power_sensor, source_entity) + if ( + sensor_config.get(CONF_CREATE_ENERGY_SENSOR) + or sensor_config.get(CONF_FORCE_ENERGY_SENSOR_CREATION) + or CONF_ENERGY_SENSOR_ID in sensor_config + ): + return create_energy_sensor(hass, sensor_config, power_sensor, source_entity) return None @@ -976,7 +1002,7 @@ def update_registries( domain_entities.extend(entities_to_add) -async def check_entity_not_already_configured( +def check_entity_not_already_configured( sensor_config: dict, source_entity: SourceEntity, hass: HomeAssistant, diff --git a/custom_components/powercalc/sensors/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/sensors/__pycache__/__init__.cpython-314.pyc index f5d2265..ae4b9c5 100644 Binary files a/custom_components/powercalc/sensors/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/sensors/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/__pycache__/abstract.cpython-314.pyc b/custom_components/powercalc/sensors/__pycache__/abstract.cpython-314.pyc index 3e0b741..ff1769d 100644 Binary files a/custom_components/powercalc/sensors/__pycache__/abstract.cpython-314.pyc and b/custom_components/powercalc/sensors/__pycache__/abstract.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/__pycache__/daily_energy.cpython-314.pyc b/custom_components/powercalc/sensors/__pycache__/daily_energy.cpython-314.pyc index 93d5a18..f0a5a39 100644 Binary files a/custom_components/powercalc/sensors/__pycache__/daily_energy.cpython-314.pyc and b/custom_components/powercalc/sensors/__pycache__/daily_energy.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/__pycache__/energy.cpython-314.pyc b/custom_components/powercalc/sensors/__pycache__/energy.cpython-314.pyc index 09b3ec8..ad404a6 100644 Binary files a/custom_components/powercalc/sensors/__pycache__/energy.cpython-314.pyc and b/custom_components/powercalc/sensors/__pycache__/energy.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/__pycache__/power.cpython-314.pyc b/custom_components/powercalc/sensors/__pycache__/power.cpython-314.pyc index dcddd1f..b99674b 100644 Binary files a/custom_components/powercalc/sensors/__pycache__/power.cpython-314.pyc and b/custom_components/powercalc/sensors/__pycache__/power.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/__pycache__/utility_meter.cpython-314.pyc b/custom_components/powercalc/sensors/__pycache__/utility_meter.cpython-314.pyc index cb79a82..3bcda5c 100644 Binary files a/custom_components/powercalc/sensors/__pycache__/utility_meter.cpython-314.pyc and b/custom_components/powercalc/sensors/__pycache__/utility_meter.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/abstract.py b/custom_components/powercalc/sensors/abstract.py index 90eb766..f4fd725 100644 --- a/custom_components/powercalc/sensors/abstract.py +++ b/custom_components/powercalc/sensors/abstract.py @@ -3,10 +3,7 @@ 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.const import CONF_NAME from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import Entity, async_generate_entity_id diff --git a/custom_components/powercalc/sensors/daily_energy.py b/custom_components/powercalc/sensors/daily_energy.py index 4c4d9a6..0e94d60 100644 --- a/custom_components/powercalc/sensors/daily_energy.py +++ b/custom_components/powercalc/sensors/daily_energy.py @@ -75,7 +75,7 @@ DAILY_FIXED_ENERGY_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__name__) -async def create_daily_fixed_energy_sensor( +def create_daily_fixed_energy_sensor( hass: HomeAssistant, sensor_config: ConfigType, source_entity: SourceEntity | None = None, @@ -134,7 +134,10 @@ async def create_daily_fixed_energy_power_sensor( 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): + 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() @@ -244,7 +247,9 @@ class DailyEnergySensor(RestoreEntity, SensorEntity, EnergySensor): self.hass, refresh, timedelta(seconds=self._update_frequency), + cancel_on_shutdown=True, ) + self.async_on_remove(self._update_timer_removal) def calculate_delta(self, elapsed_seconds: int = 0) -> Decimal: if self._last_delta_calculate is None: @@ -267,7 +272,11 @@ class DailyEnergySensor(RestoreEntity, SensorEntity, EnergySensor): ) return Decimal(0) - wh_per_day = value * (self._on_time.total_seconds() / 3600) if self._user_unit_of_measurement == UnitOfPower.WATT else value * 1000 + 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 diff --git a/custom_components/powercalc/sensors/energy.py b/custom_components/powercalc/sensors/energy.py index 7ab5b68..88e637e 100644 --- a/custom_components/powercalc/sensors/energy.py +++ b/custom_components/powercalc/sensors/energy.py @@ -60,7 +60,7 @@ ENTITY_ID_FORMAT = SENSOR_DOMAIN + ".{}" _LOGGER = logging.getLogger(__name__) -async def create_energy_sensor( +def create_energy_sensor( hass: HomeAssistant, sensor_config: ConfigType, power_sensor: PowerSensor, @@ -69,20 +69,20 @@ async def create_energy_sensor( """Create the energy sensor entity.""" # Check for existing energy sensor - energy_sensor = await _get_existing_energy_sensor(hass, sensor_config) + energy_sensor = _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) + energy_sensor = _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) + return _create_virtual_energy_sensor(hass, sensor_config, power_sensor, source_entity) -async def _get_existing_energy_sensor( +def _get_existing_energy_sensor( hass: HomeAssistant, sensor_config: ConfigType, ) -> EnergySensor | None: @@ -95,7 +95,8 @@ async def _get_existing_energy_sensor( 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", + 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, @@ -104,7 +105,7 @@ async def _get_existing_energy_sensor( ) -async def _get_related_energy_sensor( +def _get_related_energy_sensor( hass: HomeAssistant, sensor_config: ConfigType, power_sensor: PowerSensor, @@ -137,7 +138,7 @@ async def _get_related_energy_sensor( return None -async def _create_virtual_energy_sensor( +def _create_virtual_energy_sensor( hass: HomeAssistant, sensor_config: ConfigType, power_sensor: PowerSensor, @@ -188,10 +189,15 @@ def get_unit_prefix( ) -> str | None: unit_prefix = sensor_config.get(CONF_ENERGY_SENSOR_UNIT_PREFIX) - power_unit = UnitOfPower(power_sensor.unit_of_measurement) # type: ignore + try: + power_unit: UnitOfPower | str | None = ( + UnitOfPower(power_sensor.unit_of_measurement) if power_sensor.unit_of_measurement else None + ) + except ValueError: + power_unit = None 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 + if power_unit is None and power_state: + power_unit = power_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # 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 @@ -269,14 +275,16 @@ class VirtualEnergySensor(IntegrationSensor, EnergySensor): "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)), + "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] + super().__init__(**params) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] self._powercalc_source_entity = powercalc_source_entity self._powercalc_source_domain = powercalc_source_domain diff --git a/custom_components/powercalc/sensors/group/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/sensors/group/__pycache__/__init__.cpython-314.pyc index ad1c622..8e9d596 100644 Binary files a/custom_components/powercalc/sensors/group/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/sensors/group/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/group/__pycache__/config_entry_utils.cpython-314.pyc b/custom_components/powercalc/sensors/group/__pycache__/config_entry_utils.cpython-314.pyc index 1429a7c..93ddfc7 100644 Binary files a/custom_components/powercalc/sensors/group/__pycache__/config_entry_utils.cpython-314.pyc and b/custom_components/powercalc/sensors/group/__pycache__/config_entry_utils.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/group/__pycache__/custom.cpython-314.pyc b/custom_components/powercalc/sensors/group/__pycache__/custom.cpython-314.pyc index e91b5fa..a413c92 100644 Binary files a/custom_components/powercalc/sensors/group/__pycache__/custom.cpython-314.pyc and b/custom_components/powercalc/sensors/group/__pycache__/custom.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/group/__pycache__/domain.cpython-314.pyc b/custom_components/powercalc/sensors/group/__pycache__/domain.cpython-314.pyc index efbb740..c5aae9c 100644 Binary files a/custom_components/powercalc/sensors/group/__pycache__/domain.cpython-314.pyc and b/custom_components/powercalc/sensors/group/__pycache__/domain.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/group/__pycache__/factory.cpython-314.pyc b/custom_components/powercalc/sensors/group/__pycache__/factory.cpython-314.pyc index 8e5685b..b1876c3 100644 Binary files a/custom_components/powercalc/sensors/group/__pycache__/factory.cpython-314.pyc and b/custom_components/powercalc/sensors/group/__pycache__/factory.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/group/__pycache__/standby.cpython-314.pyc b/custom_components/powercalc/sensors/group/__pycache__/standby.cpython-314.pyc index c971e83..0fd68e9 100644 Binary files a/custom_components/powercalc/sensors/group/__pycache__/standby.cpython-314.pyc and b/custom_components/powercalc/sensors/group/__pycache__/standby.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/group/__pycache__/subtract.cpython-314.pyc b/custom_components/powercalc/sensors/group/__pycache__/subtract.cpython-314.pyc index b72b64d..61a953f 100644 Binary files a/custom_components/powercalc/sensors/group/__pycache__/subtract.cpython-314.pyc and b/custom_components/powercalc/sensors/group/__pycache__/subtract.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/group/__pycache__/tracked_untracked.cpython-314.pyc b/custom_components/powercalc/sensors/group/__pycache__/tracked_untracked.cpython-314.pyc index 1496089..ba6fc3a 100644 Binary files a/custom_components/powercalc/sensors/group/__pycache__/tracked_untracked.cpython-314.pyc and b/custom_components/powercalc/sensors/group/__pycache__/tracked_untracked.cpython-314.pyc differ diff --git a/custom_components/powercalc/sensors/group/config_entry_utils.py b/custom_components/powercalc/sensors/group/config_entry_utils.py index 0e0dad0..4eeaa53 100644 --- a/custom_components/powercalc/sensors/group/config_entry_utils.py +++ b/custom_components/powercalc/sensors/group/config_entry_utils.py @@ -20,11 +20,11 @@ from custom_components.powercalc.const import ( _LOGGER = logging.getLogger(__name__) -async def remove_power_sensor_from_associated_groups( +def remove_power_sensor_from_associated_groups( hass: HomeAssistant, config_entry: ConfigEntry, ) -> list[ConfigEntry]: - """When the user remove a virtual power config entry we need to update all the groups which this sensor belongs to.""" + """When the user removes a virtual power config entry, update all groups this sensor belongs to.""" group_entries = get_groups_having_member(hass, config_entry) for group_entry in group_entries: @@ -39,18 +39,18 @@ async def remove_power_sensor_from_associated_groups( return group_entries -async def add_to_associated_groups(hass: HomeAssistant, config_entry: ConfigEntry) -> ConfigEntry | None: # type: ignore +async def add_to_associated_groups(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """ When the user has set a group on a virtual power config entry, we need to add this config entry to the group members sensors and update the group. """ sensor_type = config_entry.data.get(CONF_SENSOR_TYPE) if sensor_type not in [SensorType.VIRTUAL_POWER, SensorType.DAILY_ENERGY]: - return None + return raw_groups = config_entry.data.get(CONF_GROUP) if not raw_groups: - return None + return group_ids = raw_groups if isinstance(raw_groups, list) else [raw_groups] for group_entry_id in group_ids: @@ -130,7 +130,9 @@ async def add_to_associated_group( def get_entries_having_subgroup(hass: HomeAssistant, subgroup_entry: ConfigEntry) -> list[ConfigEntry]: """Get all virtual power entries which have the subgroup in their subgroups list.""" - return [entry for entry in get_group_entries(hass) if subgroup_entry.entry_id in (entry.data.get(CONF_SUB_GROUPS) or [])] + return [ + entry for entry in get_group_entries(hass) if subgroup_entry.entry_id in (entry.data.get(CONF_SUB_GROUPS) or []) + ] def get_groups_having_member(hass: HomeAssistant, member_entry: ConfigEntry) -> list[ConfigEntry]: @@ -138,7 +140,8 @@ def get_groups_having_member(hass: HomeAssistant, member_entry: ConfigEntry) -> return [ entry for entry in hass.config_entries.async_entries(DOMAIN) - if entry.data.get(CONF_SENSOR_TYPE) == SensorType.GROUP and member_entry.entry_id in (entry.data.get(CONF_GROUP_MEMBER_SENSORS) or []) + if entry.data.get(CONF_SENSOR_TYPE) == SensorType.GROUP + and member_entry.entry_id in (entry.data.get(CONF_GROUP_MEMBER_SENSORS) or []) ] @@ -154,4 +157,6 @@ def get_group_entries(hass: HomeAssistant, group_type: GroupType | None = None) @callback def get_entries_excluding_global_config(hass: HomeAssistant) -> list[ConfigEntry]: - return [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != ENTRY_GLOBAL_CONFIG_UNIQUE_ID] + return [ + entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != ENTRY_GLOBAL_CONFIG_UNIQUE_ID + ] diff --git a/custom_components/powercalc/sensors/group/custom.py b/custom_components/powercalc/sensors/group/custom.py index c577db0..8b3dda2 100644 --- a/custom_components/powercalc/sensors/group/custom.py +++ b/custom_components/powercalc/sensors/group/custom.py @@ -59,6 +59,8 @@ from custom_components.powercalc.analytics.analytics import collect_analytics from custom_components.powercalc.const import ( ATTR_ENTITIES, ATTR_IS_GROUP, + ATTR_MEMBERS, + ATTR_STATE, CONF_ALL, CONF_AREA, CONF_CREATE_ENERGY_SENSOR, @@ -99,7 +101,14 @@ from custom_components.powercalc.const import ( UnitPrefix, ) from custom_components.powercalc.device_binding import get_device_info -from custom_components.powercalc.group_include.filter import AreaFilter, CompositeFilter, DeviceFilter, EntityFilter, FilterOperator, FloorFilter +from custom_components.powercalc.group_include.filter import ( + AreaFilter, + CompositeFilter, + DeviceFilter, + EntityFilter, + FilterOperator, + FloorFilter, +) from custom_components.powercalc.group_include.include import find_entities from custom_components.powercalc.helpers import async_cache from custom_components.powercalc.sensors.abstract import ( @@ -133,7 +142,7 @@ UNIT_CONVERTERS: dict[str | None, type[BaseUnitConverter]] = { } -async def create_group_sensors_yaml( +def create_group_sensors_yaml( hass: HomeAssistant, sensor_config: dict[str, Any], entities: list[Entity], @@ -152,7 +161,7 @@ async def create_group_sensors_yaml( ) group_name = str(sensor_config.get(CONF_CREATE_GROUP)) - return await create_group_sensors_custom(hass, group_name, sensor_config, power_sensor_ids, energy_sensor_ids) + return create_group_sensors_custom(hass, group_name, sensor_config, power_sensor_ids, energy_sensor_ids) async def create_group_sensors_gui( @@ -171,10 +180,10 @@ async def create_group_sensors_gui( energy_sensor_ids = await resolve_entity_ids_recursively(hass, entry, SensorDeviceClass.ENERGY) - return await create_group_sensors_custom(hass, group_name, sensor_config, power_sensor_ids, energy_sensor_ids) + return create_group_sensors_custom(hass, group_name, sensor_config, power_sensor_ids, energy_sensor_ids) -async def create_group_sensors_custom( +def create_group_sensors_custom( hass: HomeAssistant, group_name: str, sensor_config: dict[str, Any], @@ -216,7 +225,7 @@ async def create_group_sensors_custom( sensor_config[CONF_UTILITY_METER_NET_CONSUMPTION] = True group_sensors.extend( - await create_utility_meters( + create_utility_meters( hass, energy_sensor, sensor_config, @@ -273,64 +282,83 @@ async def resolve_entity_ids_recursively( if resolved_ids is None: resolved_ids = set() - def add_member_entry_ids() -> None: - """Add power/energy sensors from the group member entries.""" - member_entry_ids = entry.data.get(CONF_GROUP_MEMBER_SENSORS) or [] - for member_entry_id in member_entry_ids: - member_entry = hass.config_entries.async_get_entry(member_entry_id) - if member_entry is None: - continue - - key = resolve_key_based_on_device_class(member_entry) - if key and key in member_entry.data: - resolved_ids.add(str(member_entry.data.get(key))) - - def resolve_key_based_on_device_class(member_entry: ConfigEntry) -> str | None: - """Resolve the correct key for power/energy sensor based on device class.""" - if member_entry.data.get(CONF_SENSOR_TYPE) == SensorType.REAL_POWER: - return CONF_ENTITY_ID if device_class == SensorDeviceClass.POWER else ENTRY_DATA_ENERGY_ENTITY - return ENTRY_DATA_POWER_ENTITY if device_class == SensorDeviceClass.POWER else ENTRY_DATA_ENERGY_ENTITY - - def add_specified_sensors() -> None: - """Add additional power/energy sensors specified by the user.""" - conf_key = CONF_GROUP_POWER_ENTITIES if device_class == SensorDeviceClass.POWER else CONF_GROUP_ENERGY_ENTITIES - resolved_ids.update(entry.data.get(conf_key) or []) - - async def add_include_based_sensors() -> None: - """Add entities from the defined areas, devices and floors.""" - if all(k not in entry.data for k in (CONF_AREA, CONF_FLOOR, CONF_GROUP_MEMBER_DEVICES)): - return - - result = await find_entities( - hass, - await build_entity_include_filter(hass, entry), - bool(entry.data.get(CONF_INCLUDE_NON_POWERCALC_SENSORS)), - ) - resolved_ids.update(filter_entity_list_by_class(result.resolved, device_class)) - - async def add_subgroup_entities() -> None: - """Recursively add entities from subgroups.""" - subgroups = entry.data.get(CONF_SUB_GROUPS) - if not subgroups: - return - - for subgroup_entry_id in subgroups: - subgroup_entry = hass.config_entries.async_get_entry(subgroup_entry_id) - if subgroup_entry is None: - _LOGGER.error("Subgroup config entry not found: %s", subgroup_entry_id) - continue - - await resolve_entity_ids_recursively(hass, subgroup_entry, device_class, resolved_ids) - - # Process the main logic - add_member_entry_ids() - add_specified_sensors() - await add_include_based_sensors() - await add_subgroup_entities() + _add_member_entry_ids(hass, entry, device_class, resolved_ids) + _add_specified_sensors(entry, device_class, resolved_ids) + await _add_include_based_sensors(hass, entry, device_class, resolved_ids) + await _add_subgroup_entities(hass, entry, device_class, resolved_ids) return resolved_ids +def _add_member_entry_ids( + hass: HomeAssistant, + entry: ConfigEntry, + device_class: SensorDeviceClass, + resolved_ids: set[str], +) -> None: + """Add power/energy sensors from the group member entries.""" + member_entry_ids = entry.data.get(CONF_GROUP_MEMBER_SENSORS) or [] + for member_entry_id in member_entry_ids: + member_entry = hass.config_entries.async_get_entry(member_entry_id) + if member_entry is None: + continue + + key = _resolve_key_based_on_device_class(member_entry, device_class) + if key and key in member_entry.data: + resolved_ids.add(str(member_entry.data.get(key))) + + +def _resolve_key_based_on_device_class(member_entry: ConfigEntry, device_class: SensorDeviceClass) -> str | None: + """Resolve the correct key for power/energy sensor based on device class.""" + if member_entry.data.get(CONF_SENSOR_TYPE) == SensorType.REAL_POWER: + return CONF_ENTITY_ID if device_class == SensorDeviceClass.POWER else ENTRY_DATA_ENERGY_ENTITY + return ENTRY_DATA_POWER_ENTITY if device_class == SensorDeviceClass.POWER else ENTRY_DATA_ENERGY_ENTITY + + +def _add_specified_sensors(entry: ConfigEntry, device_class: SensorDeviceClass, resolved_ids: set[str]) -> None: + """Add additional power/energy sensors specified by the user.""" + conf_key = CONF_GROUP_POWER_ENTITIES if device_class == SensorDeviceClass.POWER else CONF_GROUP_ENERGY_ENTITIES + resolved_ids.update(entry.data.get(conf_key) or []) + + +async def _add_include_based_sensors( + hass: HomeAssistant, + entry: ConfigEntry, + device_class: SensorDeviceClass, + resolved_ids: set[str], +) -> None: + """Add entities from the defined areas, devices and floors.""" + if all(k not in entry.data for k in (CONF_AREA, CONF_FLOOR, CONF_GROUP_MEMBER_DEVICES)): + return + + result = await find_entities( + hass, + await build_entity_include_filter(hass, entry), + bool(entry.data.get(CONF_INCLUDE_NON_POWERCALC_SENSORS)), + ) + resolved_ids.update(filter_entity_list_by_class(result.resolved, device_class)) + + +async def _add_subgroup_entities( + hass: HomeAssistant, + entry: ConfigEntry, + device_class: SensorDeviceClass, + resolved_ids: set[str], +) -> None: + """Recursively add entities from subgroups.""" + subgroups = entry.data.get(CONF_SUB_GROUPS) + if not subgroups: + return + + for subgroup_entry_id in subgroups: + subgroup_entry = hass.config_entries.async_get_entry(subgroup_entry_id) + if subgroup_entry is None: + _LOGGER.error("Subgroup config entry not found: %s", subgroup_entry_id) + continue + + await resolve_entity_ids_recursively(hass, subgroup_entry, device_class, resolved_ids) + + @callback def create_grouped_power_sensor( hass: HomeAssistant, @@ -448,11 +476,17 @@ class GroupedSensor(BaseEntity, SensorEntity): self._entities = entities self._sensor_config = sensor_config if self._is_energy_sensor: - self._rounding_digits = int(sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION)) - self._update_interval: int = int(sensor_config.get(CONF_GROUP_ENERGY_UPDATE_INTERVAL, DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL)) + self._rounding_digits = int( + sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION), + ) + self._update_interval: int = int( + sensor_config.get(CONF_GROUP_ENERGY_UPDATE_INTERVAL, DEFAULT_GROUP_ENERGY_UPDATE_INTERVAL), + ) else: self._rounding_digits = int(sensor_config.get(CONF_POWER_SENSOR_PRECISION, DEFAULT_POWER_SENSOR_PRECISION)) - self._update_interval = int(sensor_config.get(CONF_GROUP_POWER_UPDATE_INTERVAL, DEFAULT_GROUP_POWER_UPDATE_INTERVAL)) + self._update_interval = int( + sensor_config.get(CONF_GROUP_POWER_UPDATE_INTERVAL, DEFAULT_GROUP_POWER_UPDATE_INTERVAL), + ) self._attr_suggested_display_precision = self._rounding_digits if unique_id: self._attr_unique_id = unique_id @@ -523,7 +557,11 @@ class GroupedSensor(BaseEntity, SensorEntity): domain = self._sensor_config.get(CONF_DOMAIN) if domain == CONF_ALL: entity_registry = er.async_get(self.hass) - entities = {entity.entity_id for entity in entity_registry.entities.values() if entity.device_class == self.device_class} + entities = { + entity.entity_id + for entity in entity_registry.entities.values() + if entity.device_class == self.device_class + } else: entities = self.hass.data[DOMAIN].get(DATA_DOMAIN_ENTITIES).get(domain, []) entities = filter_entity_list_by_class( @@ -533,7 +571,7 @@ class GroupedSensor(BaseEntity, SensorEntity): excluded_entities = self._sensor_config.get(CONF_EXCLUDE_ENTITIES) or [] self._entities = set({entity for entity in entities if entity not in excluded_entities}) - async def on_start(self, _: Any) -> None: # noqa + async def on_start(self, _: HomeAssistant) -> None: """Initialize group sensor when HA is starting.""" await self.init_domain_group() @@ -557,7 +595,9 @@ class GroupedSensor(BaseEntity, SensorEntity): """Initial update for the group sensor state.""" all_states = [self.hass.states.get(entity_id) for entity_id in self._entities] states: list[State] = list(filter(None, all_states)) - available_states = [state for state in states if state and state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]] + available_states = [ + state for state in states if state and state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE] + ] if not available_states and not self._ignore_unavailable_state: new_state: Decimal | str = STATE_UNAVAILABLE else: @@ -652,6 +692,37 @@ class GroupedSensor(BaseEntity, SensorEntity): def get_group_entities(self) -> dict[str, set[str]]: return {ATTR_ENTITIES: self._entities} + def debug_group(self) -> dict[str, Any]: + members: dict[str, dict[str, str | None]] = {} + for entity_id in sorted(self._entities): + members[entity_id] = self._get_member_debug_info(entity_id) + + return { + ATTR_STATE: str(self.state), + ATTR_UNIT_OF_MEASUREMENT: self.native_unit_of_measurement, + ATTR_MEMBERS: members, + } + + def _get_member_debug_info(self, entity_id: str) -> dict[str, str | None]: + state = self.hass.states.get(entity_id) + if state is None: + return { + ATTR_STATE: None, + ATTR_UNIT_OF_MEASUREMENT: None, + } + + if state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + return { + ATTR_STATE: str(state.state), + ATTR_UNIT_OF_MEASUREMENT: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), + } + + converted_value = round(self._get_state_value_in_native_unit(state), self._rounding_digits) + return { + ATTR_STATE: str(converted_value), + ATTR_UNIT_OF_MEASUREMENT: self.native_unit_of_measurement, + } + @abstractmethod def calculate_initial_state( self, @@ -681,7 +752,9 @@ class GroupedPowerSensor(GroupedSensor, PowerSensor): member_available_states: list[State], member_states: list[State], ) -> Decimal | str: - self._member_states = {state.entity_id: self._get_state_value_in_native_unit(state) for state in member_available_states} + self._member_states = { + state.entity_id: self._get_state_value_in_native_unit(state) for state in member_available_states + } return self.get_summed_state() def calculate_new_state(self, state: State) -> Decimal | str: @@ -776,8 +849,9 @@ class GroupedEnergySensor(GroupedSensor, RestoreSensor, EnergySensor): member_available_states: list[State], member_states: list[State], ) -> Decimal: - """Calculate the new group energy sensor state - For each member sensor we calculate the delta by looking at the previous known state and compare it to the current. + """Calculate the new group energy sensor state. + + For each member, calculate the delta between the previous known state and the current. """ group_sum = Decimal(self._native_value_exact) if self._native_value_exact else Decimal(0) _LOGGER.debug("%s: Recalculate, current value: %s", self.entity_id, group_sum) @@ -830,10 +904,7 @@ class GroupedEnergySensor(GroupedSensor, RestoreSensor, EnergySensor): ) start_at_zero = self._sensor_config.get(CONF_GROUP_ENERGY_START_AT_ZERO, True) - if prev_state is None and start_at_zero: # noqa: SIM108 - delta = Decimal(0) - else: - delta = cur_value - prev_value + delta = Decimal(0) if prev_state is None and start_at_zero else cur_value - prev_value if _LOGGER.isEnabledFor(logging.DEBUG): # pragma: no cover _LOGGER.debug( @@ -890,7 +961,9 @@ class PreviousStateStore: _LOGGER.debug("Load previous energy sensor states from store") stored_states = await instance.store.async_load() or {} for group, entities in stored_states.items(): - instance.states[group] = {entity_id: State.from_dict(json_state) for (entity_id, json_state) in entities.items()} + instance.states[group] = { + entity_id: State.from_dict(json_state) for (entity_id, json_state) in entities.items() + } except HomeAssistantError as exc: # pragma: no cover _LOGGER.error("Error loading previous energy sensor states", exc_info=exc) @@ -943,7 +1016,7 @@ class PreviousStateStore: def async_setup_dump(self) -> None: """Set up the listeners for persistence.""" - async def _async_dump_states(*_: Any) -> None: # noqa: ANN401 + async def _async_dump_states(*_: object) -> None: await self.persist_states() # Dump states periodically @@ -951,9 +1024,10 @@ class PreviousStateStore: self.hass, _async_dump_states, STATE_DUMP_INTERVAL, + cancel_on_shutdown=True, ) - async def _async_dump_states_at_stop(*_: Any) -> None: # noqa: ANN401 + async def _async_dump_states_at_stop(*_: object) -> None: cancel_interval() await self.persist_states() diff --git a/custom_components/powercalc/sensors/group/domain.py b/custom_components/powercalc/sensors/group/domain.py index db2a82a..80301cb 100644 --- a/custom_components/powercalc/sensors/group/domain.py +++ b/custom_components/powercalc/sensors/group/domain.py @@ -7,7 +7,7 @@ from custom_components.powercalc.const import CONF_GROUP_TYPE, GroupType from custom_components.powercalc.sensors.group.custom import create_group_sensors_custom -async def create_domain_group_sensor( +def create_domain_group_sensor( hass: HomeAssistant, config: ConfigType, ) -> list[Entity]: @@ -16,7 +16,7 @@ async def create_domain_group_sensor( if CONF_UNIQUE_ID not in config: config[CONF_UNIQUE_ID] = generate_unique_id(config) config[CONF_GROUP_TYPE] = GroupType.DOMAIN - return await create_group_sensors_custom( + return create_group_sensors_custom( hass, name, config, diff --git a/custom_components/powercalc/sensors/group/factory.py b/custom_components/powercalc/sensors/group/factory.py index bdead60..612bd61 100644 --- a/custom_components/powercalc/sensors/group/factory.py +++ b/custom_components/powercalc/sensors/group/factory.py @@ -24,12 +24,12 @@ async def create_group_sensors( collect_analytics(hass, config_entry).inc(DATA_GROUP_TYPES, group_type) if group_type == GroupType.DOMAIN: - return await domain_group.create_domain_group_sensor( + return domain_group.create_domain_group_sensor( hass, sensor_config, ) if group_type == GroupType.STANDBY: - return await standby_group.create_general_standby_sensors(hass, sensor_config) + return standby_group.create_general_standby_sensors(hass, sensor_config) if group_type == GroupType.CUSTOM: if config_entry: @@ -38,14 +38,14 @@ async def create_group_sensors( entry=config_entry, sensor_config=sensor_config, ) - return await custom_group.create_group_sensors_yaml( + return custom_group.create_group_sensors_yaml( hass=hass, sensor_config=sensor_config, entities=entities or [], ) if group_type == GroupType.SUBTRACT: - return await subtract_group.create_subtract_group_sensors( + return subtract_group.create_subtract_group_sensors( hass=hass, config=sensor_config, ) diff --git a/custom_components/powercalc/sensors/group/standby.py b/custom_components/powercalc/sensors/group/standby.py index 9bb800f..9c46372 100644 --- a/custom_components/powercalc/sensors/group/standby.py +++ b/custom_components/powercalc/sensors/group/standby.py @@ -30,7 +30,7 @@ from custom_components.powercalc.sensors.power import PowerSensor _LOGGER = logging.getLogger(__name__) -async def create_general_standby_sensors( +def create_general_standby_sensors( hass: HomeAssistant, config: ConfigType, ) -> list[Entity]: @@ -44,8 +44,8 @@ async def create_general_standby_sensors( power_sensor.entity_id = "sensor.all_standby_power" sensor_config = config.copy() sensor_config[CONF_NAME] = "All standby" - source_entity = await create_source_entity(DUMMY_ENTITY_ID, hass) - energy_sensor = await create_energy_sensor( + source_entity = create_source_entity(DUMMY_ENTITY_ID, hass) + energy_sensor = create_energy_sensor( hass, sensor_config, power_sensor, @@ -80,8 +80,8 @@ class StandbyPowerSensor(SensorEntity, PowerSensor): """Calculate sum of all power sensors in standby, and update the state of the sensor.""" if self.standby_sensors: self._attr_native_value = Decimal( - round( # type: ignore - sum(self.standby_sensors.values()), + round( + sum(self.standby_sensors.values(), Decimal(0)), self._rounding_digits, ), ) diff --git a/custom_components/powercalc/sensors/group/subtract.py b/custom_components/powercalc/sensors/group/subtract.py index 3cf6b76..7366d98 100644 --- a/custom_components/powercalc/sensors/group/subtract.py +++ b/custom_components/powercalc/sensors/group/subtract.py @@ -24,7 +24,7 @@ from custom_components.powercalc.sensors.utility_meter import create_utility_met _LOGGER = logging.getLogger(__name__) -async def create_subtract_group_sensors( +def create_subtract_group_sensors( hass: HomeAssistant, config: ConfigType, ) -> list[Entity]: @@ -58,7 +58,7 @@ async def create_subtract_group_sensors( ) sensors.append(power_sensor) if config.get(CONF_CREATE_ENERGY_SENSORS): - energy_sensor = await create_energy_sensor( + energy_sensor = create_energy_sensor( hass, config, power_sensor, @@ -67,7 +67,7 @@ async def create_subtract_group_sensors( config[CONF_UTILITY_METER_NET_CONSUMPTION] = True sensors.extend( - await create_utility_meters( + create_utility_meters( hass, energy_sensor, config, diff --git a/custom_components/powercalc/sensors/group/tracked_untracked.py b/custom_components/powercalc/sensors/group/tracked_untracked.py index bc485b2..4bb0a6d 100644 --- a/custom_components/powercalc/sensors/group/tracked_untracked.py +++ b/custom_components/powercalc/sensors/group/tracked_untracked.py @@ -2,7 +2,6 @@ from __future__ import annotations from enum import StrEnum import logging -from typing import Any from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntry @@ -53,7 +52,11 @@ async def find_auto_tracked_power_entities(hass: HomeAssistant, exclude_entities if exclude_entities: entity_filter = LambdaFilter(lambda entity: entity.entity_id not in exclude_entities) result = await find_entities(hass, entity_filter) - return {entity.entity_id for entity in result.resolved if isinstance(entity, PowerSensor) and not isinstance(entity, GroupedSensor)} + return { + entity.entity_id + for entity in result.resolved + if isinstance(entity, PowerSensor) and not isinstance(entity, GroupedSensor) + } class TrackedPowerSensorFactory: @@ -67,7 +70,9 @@ class TrackedPowerSensorFactory: """Create tracked/untracked group sensors.""" unique_id = str(self.config.get(CONF_UNIQUE_ID)) - main_power_sensor = str(self.config.get(CONF_MAIN_POWER_SENSOR)) if self.config.get(CONF_MAIN_POWER_SENSOR) else None + main_power_sensor = ( + str(self.config.get(CONF_MAIN_POWER_SENSOR)) if self.config.get(CONF_MAIN_POWER_SENSOR) else None + ) self.config[CONF_DISABLE_EXTENDED_ATTRIBUTES] = True # prevent adding all entities in the state attributes self.tracked_entities = await self.get_tracked_power_entities() @@ -83,7 +88,7 @@ class TrackedPowerSensorFactory: energy_sensor = await self.create_energy_sensor(SensorType.TRACKED, tracked_sensor) entities.append(energy_sensor) entities.extend( - await create_utility_meters( + create_utility_meters( self.hass, energy_sensor, {CONF_UTILITY_METER_NET_CONSUMPTION: True, **self.config}, @@ -102,7 +107,7 @@ class TrackedPowerSensorFactory: energy_sensor = await self.create_energy_sensor(SensorType.UNTRACKED, untracked_sensor) entities.append(energy_sensor) entities.extend( - await create_utility_meters( + create_utility_meters( self.hass, energy_sensor, {CONF_UTILITY_METER_NET_CONSUMPTION: True, **self.config}, @@ -116,12 +121,15 @@ class TrackedPowerSensorFactory: Get all power entities which are part of the tracked sensor group """ if not bool(self.config.get(CONF_GROUP_TRACKED_AUTO, False)): - return set(self.config.get(CONF_GROUP_TRACKED_POWER_ENTITIES)) # type: ignore + tracked_entities: list[str] | None = self.config.get(CONF_GROUP_TRACKED_POWER_ENTITIES) + if not isinstance(tracked_entities, list): + return set() + return set(tracked_entities) # For auto mode, we also want to listen for any changes in the entity registry # Dynamically add/remove power sensors from the tracked group @callback - def _start_entity_registry_listener(_: Any) -> None: # noqa ANN401 + def _start_entity_registry_listener(_: Event) -> None: self.hass.bus.async_listen(EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entity_registry_updated) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _start_entity_registry_listener) @@ -137,8 +145,9 @@ class TrackedPowerSensorFactory: entity_id = event.data["entity_id"] action = event.data["action"] - if action == "update" and "old_entity_id" in event.data: - if event.data["old_entity_id"] in self.tracked_entities: # type: ignore + old_entity_id = event.data.get("old_entity_id") + if action == "update" and old_entity_id is not None: + if old_entity_id in self.tracked_entities: return await self.reload() return None # pragma: no cover diff --git a/custom_components/powercalc/sensors/power.py b/custom_components/powercalc/sensors/power.py index a209286..93e27d0 100644 --- a/custom_components/powercalc/sensors/power.py +++ b/custom_components/powercalc/sensors/power.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable, Coroutine from copy import copy from datetime import datetime, timedelta from decimal import Decimal @@ -25,6 +26,7 @@ from homeassistant.const import ( from homeassistant.core import ( CALLBACK_TYPE, Event, + HassJob, HomeAssistant, State, callback, @@ -37,6 +39,7 @@ import homeassistant.helpers.entity_registry as er from homeassistant.helpers.event import ( EventStateChangedData, TrackTemplate, + TrackTemplateResult, async_call_later, async_track_state_change_event, async_track_template_result, @@ -122,7 +125,7 @@ async def create_power_sensor( """Create the power sensor based on powercalc sensor configuration.""" if CONF_POWER_SENSOR_ID in sensor_config: # Use an existing power sensor, only create energy sensors / utility meters - return await create_real_power_sensor(hass, sensor_config) + return create_real_power_sensor(hass, sensor_config) return await create_virtual_power_sensor( hass, @@ -147,7 +150,11 @@ async def create_virtual_power_sensor( if CONF_CALCULATION_ENABLED_CONDITION not in sensor_config and power_profile.calculation_enabled_condition: sensor_config[CONF_CALCULATION_ENABLED_CONDITION] = power_profile.calculation_enabled_condition - if config_entry and await power_profile.requires_manual_sub_profile_selection and "/" not in sensor_config.get(CONF_MODEL, ""): + if ( + config_entry + and await power_profile.requires_manual_sub_profile_selection + and "/" not in sensor_config.get(CONF_MODEL, "") + ): ir.async_create_issue( hass, DOMAIN, @@ -181,10 +188,14 @@ async def create_virtual_power_sensor( a = collect_analytics(hass, config_entry) a.inc(DATA_STRATEGIES, strategy) a.add(DATA_POWER_PROFILES, power_profile) - a.inc(DATA_POWER_PROFILE_SOURCES, power_profile.configuration_source if power_profile else PowerProfileSource.MANUAL) + a.inc( + DATA_POWER_PROFILE_SOURCES, + power_profile.configuration_source if power_profile else PowerProfileSource.MANUAL, + ) _LOGGER.debug( - "Creating power sensor (entity_id=%s entity_category=%s, sensor_name=%s strategy=%s manufacturer=%s model=%s unique_id=%s)", + "Creating power sensor (entity_id=%s entity_category=%s, sensor_name=%s strategy=%s " + "manufacturer=%s model=%s unique_id=%s)", source_entity.entity_id, entity_category, name, @@ -313,7 +324,7 @@ def _get_standby_power( return standby_power, standby_power_on -async def create_real_power_sensor( +def create_real_power_sensor( hass: HomeAssistant, sensor_config: dict, ) -> RealPowerSensor: @@ -450,7 +461,7 @@ class VirtualPowerSensor(SensorEntity, PowerSensor): new_state, ) - async def template_change_listener(*_: Any) -> None: # noqa: ANN401 + async def template_change_listener(*_: object) -> None: """Handle for state changes for referenced templates.""" state = self.hass.states.get(self._source_entity.entity_id) await self._handle_source_entity_state_change( @@ -461,9 +472,9 @@ class VirtualPowerSensor(SensorEntity, PowerSensor): async def initial_update(hass: HomeAssistant) -> None: """Calculate initial value and push state""" - # When using reload service energy sensor became unavailable - # This is caused because state change listener of energy sensor is registered before power sensor pushes initial update - # Adding sleep 0 fixes this issue. + # When using reload service the energy sensor became unavailable. + # This is caused because the state change listener of the energy sensor is registered + # before the power sensor pushes its initial update. Adding sleep 0 fixes this issue. await asyncio.sleep(0) if self._strategy_instance: await self._strategy_instance.on_start(hass) @@ -472,7 +483,9 @@ class VirtualPowerSensor(SensorEntity, PowerSensor): if (not entities and self._source_entity.entity_id == DUMMY_ENTITY_ID) or not entities: entities.add(DUMMY_ENTITY_ID) for entity_id in entities: - new_state = self.hass.states.get(entity_id) if entity_id != DUMMY_ENTITY_ID else State(entity_id, STATE_ON) + new_state = ( + self.hass.states.get(entity_id) if entity_id != DUMMY_ENTITY_ID else State(entity_id, STATE_ON) + ) await self._handle_source_entity_state_change( entity_id, new_state, @@ -480,30 +493,56 @@ class VirtualPowerSensor(SensorEntity, PowerSensor): # Add listeners for all tracking entities and templates. entities_to_track = self._get_tracking_entities() - - self._track_entities = {e for e in entities_to_track if isinstance(e, str)} - self.async_on_remove( - async_track_state_change_event(self.hass, self._track_entities, appliance_state_listener), - ) - - track_templates: list[TrackTemplate] = [e for e in entities_to_track if isinstance(e, TrackTemplate)] - if track_templates: - async_track_template_result(self.hass, track_templates=track_templates, action=template_change_listener) + self._register_tracking_listeners(entities_to_track, appliance_state_listener, template_change_listener) # Trigger initial update self.async_on_remove(start.async_at_start(self.hass, initial_update)) - if hasattr(self._strategy_instance, "set_update_callback"): - self._strategy_instance.set_update_callback(self._update_power_sensor) + self._strategy_instance.set_update_callback(self._update_power_sensor) + self._register_force_update_interval() + + def _register_tracking_listeners( + self, + entities_to_track: list[str | TrackTemplate], + appliance_state_listener: Callable[[Event[EventStateChangedData]], Awaitable[None]], + template_change_listener: Callable[ + [Event[EventStateChangedData] | None, list[TrackTemplateResult]], + Coroutine[Any, Any, None] | None, + ], + ) -> None: + self._track_entities = {e for e in entities_to_track if isinstance(e, str)} + self.async_on_remove( + async_track_state_change_event(self.hass, self._track_entities, appliance_state_listener), + ) + track_templates: list[TrackTemplate] = [e for e in entities_to_track if isinstance(e, TrackTemplate)] + if track_templates: + template_tracker = async_track_template_result( + self.hass, + track_templates=track_templates, + action=template_change_listener, + ) + self.async_on_remove( + template_tracker.async_remove, + ) + + def _register_force_update_interval(self) -> None: force_update_interval = self._sensor_config.get(CONF_POWER_UPDATE_INTERVAL, 0) - if force_update_interval > 0: + if force_update_interval <= 0: + return - @callback - def async_update(__: datetime | None = None) -> None: - self.async_schedule_update_ha_state(True) + @callback + def async_update(__: datetime | None = None) -> None: + self.async_schedule_update_ha_state(True) - async_track_time_interval(self.hass, async_update, timedelta(seconds=force_update_interval)) + self.async_on_remove( + async_track_time_interval( + self.hass, + async_update, + timedelta(seconds=force_update_interval), + cancel_on_shutdown=True, + ), + ) def _get_tracking_entities(self) -> list[str | TrackTemplate]: """Return entities and templates that should be tracked.""" @@ -616,16 +655,8 @@ class VirtualPowerSensor(SensorEntity, PowerSensor): """Calculate power consumption using configured strategy.""" assert self._strategy_instance is not None - # Resolve the relevant entity state - entity_state = state - if self._source_entity.entity_id == DUMMY_ENTITY_ID and self._calculation_strategy != CalculationStrategy.MULTI_SWITCH: - if self._availability_entity and state.entity_id == self._availability_entity: - entity_state = State(DUMMY_ENTITY_ID, STATE_ON) - elif ( - self._calculation_strategy != CalculationStrategy.MULTI_SWITCH - and state.entity_id != self._source_entity.entity_id - and (entity_state := self.hass.states.get(self._source_entity.entity_id)) is None - ): + entity_state = self._resolve_calculation_state(state) + if entity_state is None: return None # Handle unavailable power @@ -633,31 +664,56 @@ class VirtualPowerSensor(SensorEntity, PowerSensor): if entity_state.state == STATE_UNAVAILABLE and unavailable_power is not None: return Decimal(unavailable_power) - # Handle standby power - standby_power = None - if entity_state.state in self._off_states or not await self.is_calculation_enabled(entity_state): - if isinstance(self._strategy_instance, PlaybookStrategy): - await self._strategy_instance.stop_playbook() - standby_power = await self.calculate_standby_power(entity_state) - self._standby_sensors[self.entity_id] = standby_power - - if self._strategy_instance.can_calculate_standby() or self._calculation_strategy != CalculationStrategy.MULTI_SWITCH: - return standby_power + standby_power = await self._calculate_state_standby_power(entity_state) + if standby_power is not None and ( + self._strategy_instance.can_calculate_standby() + or self._calculation_strategy != CalculationStrategy.MULTI_SWITCH + ): + return standby_power # Calculate actual power using configured strategy power = await self._strategy_instance.calculate(entity_state) if power is None: return None - # Add standby power if available + return Decimal(self._apply_power_adjustments(power, standby_power)) + + def _resolve_calculation_state(self, state: State) -> State | None: + if ( + self._source_entity.entity_id == DUMMY_ENTITY_ID + and self._calculation_strategy != CalculationStrategy.MULTI_SWITCH + ): + if self._availability_entity and state.entity_id == self._availability_entity: + return State(DUMMY_ENTITY_ID, STATE_ON) + return state + + if ( + self._calculation_strategy == CalculationStrategy.MULTI_SWITCH + or state.entity_id == self._source_entity.entity_id + ): + return state + + return cast(State | None, self.hass.states.get(self._source_entity.entity_id)) + + async def _calculate_state_standby_power(self, entity_state: State) -> Decimal | None: + if entity_state.state not in self._off_states and await self.is_calculation_enabled(entity_state): + return None + + assert self._strategy_instance is not None + if isinstance(self._strategy_instance, PlaybookStrategy): + await self._strategy_instance.stop_playbook() + + standby_power = await self.calculate_standby_power(entity_state) + self._standby_sensors[self.entity_id] = standby_power + return standby_power + + def _apply_power_adjustments(self, power: Decimal, standby_power: Decimal | None) -> Decimal: if standby_power: power += standby_power - # Apply multiply factor to power if self._multiply_factor: power *= Decimal(self._multiply_factor) - # Add standby power-on adjustments if applicable if self._standby_power_on and not standby_power: additional_standby_power = self._standby_power_on self._standby_sensors[self.entity_id] = self._standby_power_on @@ -665,7 +721,7 @@ class VirtualPowerSensor(SensorEntity, PowerSensor): additional_standby_power *= Decimal(self._multiply_factor) power += additional_standby_power - return Decimal(power) + return power async def _switch_sub_profile_dynamically(self, state: State) -> None: """Dynamically select a different sub profile depending on the entity state or attributes @@ -698,7 +754,7 @@ class VirtualPowerSensor(SensorEntity, PowerSensor): delay = sleep_power.get(CONF_DELAY) or 0 @callback - def _update_sleep_power(*_: Any) -> None: # noqa: ANN401 + def _update_sleep_power(*_: object) -> None: power = Decimal(sleep_power.get(CONF_POWER) or 0) if self._multiply_factor_standby and self._multiply_factor: power *= Decimal(self._multiply_factor) @@ -707,14 +763,14 @@ class VirtualPowerSensor(SensorEntity, PowerSensor): self._sleep_power_timer = async_call_later( self.hass, delay, - _update_sleep_power, + HassJob(_update_sleep_power, name=f"{self.entity_id} sleep power", cancel_on_shutdown=True), ) standby_power = self._standby_power if self._strategy_instance.can_calculate_standby(): standby_power = await self._strategy_instance.calculate(state) or self._standby_power - evaluated = await evaluate_power(standby_power) + evaluated = evaluate_power(standby_power) if evaluated is None: evaluated = Decimal(0) standby_power = evaluated @@ -775,9 +831,22 @@ class VirtualPowerSensor(SensorEntity, PowerSensor): raise HomeAssistantError("supported only playbook enabled sensors") return self._strategy_instance + async def async_will_remove_from_hass(self) -> None: + """Cancel outstanding timers when the entity is removed.""" + if self._sleep_power_timer is not None: + self._sleep_power_timer() + self._sleep_power_timer = None + if isinstance(self._strategy_instance, PlaybookStrategy): + await self._strategy_instance.stop_playbook() + await super().async_will_remove_from_hass() + async def async_switch_sub_profile(self, profile: str) -> None: """Switches to a new sub profile""" - if not self._power_profile or not await self._power_profile.has_sub_profiles or self._power_profile.sub_profile_select: + if ( + not self._power_profile + or not await self._power_profile.has_sub_profiles + or self._power_profile.sub_profile_select + ): raise HomeAssistantError( "This is only supported for sensors having sub profiles, and no automatic profile selection", ) diff --git a/custom_components/powercalc/sensors/utility_meter.py b/custom_components/powercalc/sensors/utility_meter.py index 3a9a2ff..8d51276 100644 --- a/custom_components/powercalc/sensors/utility_meter.py +++ b/custom_components/powercalc/sensors/utility_meter.py @@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__) GENERAL_TARIFF = "general" -async def create_utility_meters( +def create_utility_meters( hass: HomeAssistant, energy_sensor: EnergySensor, sensor_config: dict, @@ -62,7 +62,7 @@ async def create_utility_meters( unique_id = f"{energy_sensor.unique_id}_{meter_type}" if energy_sensor.unique_id else None if should_create_utility_meter(hass, unique_id, energy_sensor): utility_meters.extend( - await create_meters_for_type( + create_meters_for_type( hass, energy_sensor, sensor_config, @@ -98,7 +98,7 @@ def should_create_utility_meter( return not (existing_entity_id and hass.states.get(existing_entity_id)) # pragma: no cover -async def create_meters_for_type( +def create_meters_for_type( hass: HomeAssistant, energy_sensor: EnergySensor, sensor_config: dict, @@ -116,7 +116,7 @@ async def create_meters_for_type( # Create generic utility meter if not tariffs or GENERAL_TARIFF in tariffs: - utility_meter = await create_utility_meter( + utility_meter = create_utility_meter( hass, energy_sensor.entity_id, entity_id, @@ -130,7 +130,7 @@ async def create_meters_for_type( # Create tariff-specific utility meters if tariffs: - new_tariff_sensors = await create_tariff_meters( + new_tariff_sensors = create_tariff_meters( hass, energy_sensor, entity_id, @@ -148,7 +148,7 @@ async def create_meters_for_type( return utility_meters -async def create_tariff_meters( +def create_tariff_meters( hass: HomeAssistant, energy_sensor: EnergySensor, entity_id: str, @@ -161,11 +161,11 @@ async def create_tariff_meters( ) -> list[VirtualUtilityMeter]: """Create utility meters for specific tariffs.""" filtered_tariffs = [t for t in tariffs if t != GENERAL_TARIFF] - tariff_select = await create_tariff_select(config_entry, filtered_tariffs, hass, name, unique_id) + tariff_select = create_tariff_select(config_entry, filtered_tariffs, hass, name, unique_id) tariff_sensors = [] for tariff in filtered_tariffs: - utility_meter = await create_utility_meter( + utility_meter = create_utility_meter( hass, energy_sensor.entity_id, entity_id, @@ -181,7 +181,7 @@ async def create_tariff_meters( return tariff_sensors -async def create_tariff_select( +def create_tariff_select( config_entry: ConfigEntry | None, tariffs: list, hass: HomeAssistant, @@ -215,7 +215,7 @@ async def create_tariff_select( return tariff_select -async def create_utility_meter( +def create_utility_meter( hass: HomeAssistant, source_entity: str, entity_id: str, @@ -258,7 +258,9 @@ async def create_utility_meter( params = {key: value for key, value in params.items() if key in signature.parameters} utility_meter = VirtualUtilityMeter(**params) # type: ignore[no-untyped-call] - utility_meter.rounding_digits = int(sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION)) + utility_meter.rounding_digits = int( + sensor_config.get(CONF_ENERGY_SENSOR_PRECISION, DEFAULT_ENERGY_SENSOR_PRECISION), + ) utility_meter.entity_id = entity_id return utility_meter @@ -280,8 +282,10 @@ class VirtualUtilityMeter(UtilityMeterSensor, BaseEntity): @property def native_value(self) -> StateType | Decimal: """Return the state of the sensor.""" - value = self._state if hasattr(self, "_state") else self._attr_native_value # pre HA 2024.12 value was stored in _state + value = ( + self._state if hasattr(self, "_state") else self._attr_native_value + ) # pre HA 2024.12 value was stored in _state if self.rounding_digits and value is not None: - return Decimal(round(value, self.rounding_digits)) # type: ignore + return Decimal(round(value, self.rounding_digits)) # type: ignore[arg-type] - return value # type: ignore + return value # type: ignore[return-value] diff --git a/custom_components/powercalc/service/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/service/__pycache__/__init__.cpython-314.pyc index 75a0ead..80bba73 100644 Binary files a/custom_components/powercalc/service/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/service/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/service/__pycache__/gui_configuration.cpython-314.pyc b/custom_components/powercalc/service/__pycache__/gui_configuration.cpython-314.pyc index 895b854..3756058 100644 Binary files a/custom_components/powercalc/service/__pycache__/gui_configuration.cpython-314.pyc and b/custom_components/powercalc/service/__pycache__/gui_configuration.cpython-314.pyc differ diff --git a/custom_components/powercalc/services.yaml b/custom_components/powercalc/services.yaml index db78c82..b648f97 100644 --- a/custom_components/powercalc/services.yaml +++ b/custom_components/powercalc/services.yaml @@ -70,6 +70,13 @@ get_group_entities: entity: domain: sensor integration: powercalc +debug_group: + name: Debug group + description: Get a debug overview of a Powercalc power or energy group including current member values + target: + entity: + domain: sensor + integration: powercalc increase_daily_energy: name: Increase daily energy sensor description: Increases the sensor with a given amount diff --git a/custom_components/powercalc/strategy/__pycache__/__init__.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/__init__.cpython-314.pyc index 61eaf0c..11d7459 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/__init__.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/__pycache__/composite.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/composite.cpython-314.pyc index b8c4408..8626c40 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/composite.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/composite.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/__pycache__/factory.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/factory.cpython-314.pyc index e986cb1..13c289e 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/factory.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/factory.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/__pycache__/fixed.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/fixed.cpython-314.pyc index 40ee407..eb58b3b 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/fixed.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/fixed.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/__pycache__/linear.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/linear.cpython-314.pyc index cecb81a..e8fe2a9 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/linear.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/linear.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/__pycache__/lut.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/lut.cpython-314.pyc index f0646e4..5671f51 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/lut.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/lut.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/__pycache__/multi_switch.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/multi_switch.cpython-314.pyc index 77a03cb..18da868 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/multi_switch.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/multi_switch.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/__pycache__/playbook.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/playbook.cpython-314.pyc index b920448..5a2b78f 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/playbook.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/playbook.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/__pycache__/selector.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/selector.cpython-314.pyc index c9643f7..b0dfbb3 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/selector.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/selector.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/__pycache__/strategy_interface.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/strategy_interface.cpython-314.pyc index 3a619be..533cbbc 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/strategy_interface.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/strategy_interface.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/__pycache__/wled.cpython-314.pyc b/custom_components/powercalc/strategy/__pycache__/wled.cpython-314.pyc index 9c93c4f..2774300 100644 Binary files a/custom_components/powercalc/strategy/__pycache__/wled.cpython-314.pyc and b/custom_components/powercalc/strategy/__pycache__/wled.cpython-314.pyc differ diff --git a/custom_components/powercalc/strategy/composite.py b/custom_components/powercalc/strategy/composite.py index 92ee7cf..6cb6b41 100644 --- a/custom_components/powercalc/strategy/composite.py +++ b/custom_components/powercalc/strategy/composite.py @@ -46,7 +46,7 @@ DEFAULT_MODE = CompositeMode.STOP_AT_FIRST def make_entity_id_optional(schema: vol.Schema) -> vol.Schema: """Make entity_id optional in schema.""" schema = schema.schema - schema[vol.Optional(CONF_ENTITY_ID)] = schema.pop(vol.Required(CONF_ENTITY_ID)) # type: ignore + schema[vol.Optional(CONF_ENTITY_ID)] = schema.pop(vol.Required(CONF_ENTITY_ID)) # type: ignore[index, attr-defined] return vol.Schema(schema) @@ -55,17 +55,17 @@ def get_numeric_state_schema() -> vol.Schema: return make_entity_id_optional(cv.NUMERIC_STATE_CONDITION_SCHEMA.validators[0]) -def get_state_condition_attribute_schema(value: Any) -> dict[str, Any]: # noqa: ANN401 +def get_state_condition_attribute_schema(value: object) -> dict[str, Any]: """Return the state attribute condition schema. We need to modify it to make entity_id optional.""" - return make_entity_id_optional(cv.STATE_CONDITION_ATTRIBUTE_SCHEMA)(value) # type: ignore + return make_entity_id_optional(cv.STATE_CONDITION_ATTRIBUTE_SCHEMA)(value) # type: ignore[no-any-return] -def get_state_condition_state_schema(value: Any) -> dict[str, Any]: # noqa: ANN401 +def get_state_condition_state_schema(value: object) -> dict[str, Any]: """Return the state condition schema. We need to modify it to make entity_id optional.""" - return make_entity_id_optional(cv.STATE_CONDITION_STATE_SCHEMA)(value) # type: ignore + return make_entity_id_optional(cv.STATE_CONDITION_STATE_SCHEMA)(value) # type: ignore[no-any-return] -def get_state_schema(value: Any) -> dict[str, Any]: # noqa: ANN401 +def get_state_schema(value: object) -> dict[str, Any]: """Validate a state condition.""" if not isinstance(value, dict): raise vol.Invalid("Expected a dictionary") # pragma: no cover @@ -158,7 +158,9 @@ class CompositeStrategy(PowerCalculationStrategyInterface): if isinstance(strategy, PlaybookStrategy): await self.activate_playbook(strategy) - if (entity_state.state == STATE_OFF and strategy.can_calculate_standby()) or entity_state.state != STATE_OFF: + if ( + entity_state.state == STATE_OFF and strategy.can_calculate_standby() + ) or entity_state.state != STATE_OFF: value = await strategy.calculate(entity_state) if value is not None: if self.mode == CompositeMode.STOP_AT_FIRST: @@ -186,8 +188,7 @@ class CompositeStrategy(PowerCalculationStrategyInterface): and manipulate the state """ for sub_strategy in self.strategies: - if hasattr(sub_strategy.strategy, "set_update_callback"): - sub_strategy.strategy.set_update_callback(update_callback) + sub_strategy.strategy.set_update_callback(update_callback) async def validate_config(self) -> None: """Validate correct setup of the strategy.""" @@ -204,7 +205,9 @@ class CompositeStrategy(PowerCalculationStrategyInterface): track_templates, ) - track_entities = [entity for sub_strategy in self.strategies for entity in sub_strategy.strategy.get_entities_to_track()] + track_entities = [ + entity for sub_strategy in self.strategies for entity in sub_strategy.strategy.get_entities_to_track() + ] return track_templates + track_entities def can_calculate_standby(self) -> bool: diff --git a/custom_components/powercalc/strategy/factory.py b/custom_components/powercalc/strategy/factory.py index 9176093..65916ba 100644 --- a/custom_components/powercalc/strategy/factory.py +++ b/custom_components/powercalc/strategy/factory.py @@ -171,11 +171,13 @@ class PowerCalculatorStrategyFactory: else: raise StrategyConfigurationError("No composite configuration supplied") - sub_strategies = composite_config + sub_strategies: list[ConfigType] mode = DEFAULT_MODE if isinstance(composite_config, dict): mode = composite_config.get(CONF_MODE, DEFAULT_MODE) - sub_strategies = composite_config.get(CONF_STRATEGIES) # type: ignore + sub_strategies = composite_config.get(CONF_STRATEGIES, []) + else: + sub_strategies = composite_config async def _create_sub_strategy(strategy_config: ConfigType) -> SubStrategy: condition_instance = None @@ -200,6 +202,8 @@ class PowerCalculatorStrategyFactory: ) return SubStrategy(condition_config, condition_instance, strategy_instance) # type: ignore + if not sub_strategies: + raise StrategyConfigurationError("No strategies configured for composite strategy") strategies = [await _create_sub_strategy(config) for config in sub_strategies] return CompositeStrategy(self._hass, strategies, mode) diff --git a/custom_components/powercalc/strategy/fixed.py b/custom_components/powercalc/strategy/fixed.py index 2707581..fc1b739 100644 --- a/custom_components/powercalc/strategy/fixed.py +++ b/custom_components/powercalc/strategy/fixed.py @@ -46,7 +46,7 @@ class FixedStrategy(PowerCalculationStrategyInterface): if self._per_state_power is not None: # Lookup by state if entity_state.state in self._per_state_power: - return await evaluate_power( + return evaluate_power( self._per_state_power.get(entity_state.state) or 0, ) @@ -55,12 +55,12 @@ class FixedStrategy(PowerCalculationStrategyInterface): if "|" in state_key: attribute, value = state_key.split("|", 2) if str(entity_state.attributes.get(attribute)) == value: - return await evaluate_power(power) + return evaluate_power(power) if self._power is None: return None - return await evaluate_power(self._power) + return evaluate_power(self._power) async def validate_config(self) -> None: """Validate correct setup of the strategy.""" @@ -84,8 +84,10 @@ class FixedStrategy(PowerCalculationStrategyInterface): track_templates.append(TrackTemplate(self._power, None, None)) if self._per_state_power: - for power in list(self._per_state_power.values()): - if isinstance(power, Template): - track_templates.append(TrackTemplate(power, None, None)) # noqa: PERF401 + track_templates.extend( + TrackTemplate(power, None, None) + for power in self._per_state_power.values() + if isinstance(power, Template) + ) return track_templates diff --git a/custom_components/powercalc/strategy/linear.py b/custom_components/powercalc/strategy/linear.py index 4359872..dccc25a 100644 --- a/custom_components/powercalc/strategy/linear.py +++ b/custom_components/powercalc/strategy/linear.py @@ -25,6 +25,8 @@ from custom_components.powercalc.const import ( CONF_GAMMA_CURVE, CONF_MAX_POWER, CONF_MIN_POWER, + CONF_POWER, + CONF_VALUE, ) from custom_components.powercalc.errors import StrategyConfigurationError from custom_components.powercalc.helpers import get_related_entity_by_device_class @@ -78,6 +80,8 @@ class LinearStrategy(PowerCalculationStrategyInterface): async def calculate(self, entity_state: State) -> Decimal | None: """Calculate the current power consumption.""" + value_entity = self.get_initialized_value_entity() + if not self._initialized: self._attribute = self.get_attribute(entity_state) self._initialized = True @@ -93,7 +97,7 @@ class LinearStrategy(PowerCalculationStrategyInterface): _LOGGER.debug( "%s: Linear mode state value: %d range(%d-%d)", - self._value_entity.entity_id, # type: ignore + value_entity.entity_id, value, min_value, max_value, @@ -115,9 +119,7 @@ class LinearStrategy(PowerCalculationStrategyInterface): def is_enabled(self, entity_state: State) -> bool: """Return if this strategy is enabled based on entity state.""" - if self._source_entity.domain == media_player.DOMAIN and entity_state.state is not STATE_PLAYING: # noqa: SIM103 - return False - return True + return not (self._source_entity.domain == media_player.DOMAIN and entity_state.state is not STATE_PLAYING) def get_min_calibrate(self, value: int) -> tuple[int, float]: """Get closest lower value from calibration table.""" @@ -134,16 +136,19 @@ class LinearStrategy(PowerCalculationStrategyInterface): calibrate = self._config.get(CONF_CALIBRATE) if isinstance(calibrate, dict): calibrate = [f"{key} -> {value}" for key, value in calibrate.items()] + elif isinstance(calibrate, list) and calibrate and isinstance(calibrate[0], dict): + calibrate = [f"{item[CONF_VALUE]} -> {item[CONF_POWER]}" for item in calibrate] if calibrate is None or len(calibrate) == 0: full_range = self.get_entity_value_range() min_value = full_range[0] max_value = full_range[1] min_power = self._config.get(CONF_MIN_POWER) or self._standby_power or 0 + max_power = self._config.get(CONF_MAX_POWER) + if max_power is None: # pragma: no cover + raise StrategyConfigurationError("Linear strategy must have max power defined") calibration_list.append((min_value, float(min_power))) - calibration_list.append( - (max_value, float(self._config.get(CONF_MAX_POWER))), # type: ignore - ) + calibration_list.append((max_value, float(max_power))) return calibration_list for line in calibrate: @@ -154,23 +159,30 @@ class LinearStrategy(PowerCalculationStrategyInterface): def get_entity_value_range(self) -> tuple: """Get the min/max range for a given entity domain.""" - if self._value_entity.domain == light.DOMAIN: # type: ignore + if self.get_initialized_value_entity().domain == light.DOMAIN: return 0, 255 return 0, 100 + def get_initialized_value_entity(self) -> SourceEntity: + """Return the initialized value entity.""" + if self._value_entity is None: # pragma: no cover + raise StrategyConfigurationError("Linear strategy has not been initialized") + return self._value_entity + def get_current_state_value(self, entity_state: State) -> int | None: """Get the current entity state, i.e. selected brightness.""" if self._attribute: return self.get_value_from_attribute(entity_state) - if self._value_entity.entity_id is not self._source_entity.entity_id: # type: ignore + value_entity = self.get_initialized_value_entity() + if value_entity.entity_id is not self._source_entity.entity_id: # If the value entity is different from the source entity, we need to fetch the state of the value entity - entity_state = self._hass.states.get(self._value_entity.entity_id) # type: ignore + entity_state = self._hass.states.get(value_entity.entity_id) if not entity_state: _LOGGER.error( "Value entity %s not found", - self._value_entity.entity_id, # type: ignore + value_entity.entity_id, ) return None @@ -184,7 +196,10 @@ class LinearStrategy(PowerCalculationStrategyInterface): return None def get_value_from_attribute(self, entity_state: State) -> int | None: - value: int | None = entity_state.attributes.get(self._attribute) # type: ignore[arg-type] + if self._attribute is None: # pragma: no cover + return None + + value = entity_state.attributes.get(self._attribute) if value is None: _LOGGER.warning( "No %s attribute for entity: %s", @@ -192,13 +207,15 @@ class LinearStrategy(PowerCalculationStrategyInterface): entity_state.entity_id, ) return None - if self._attribute == ATTR_BRIGHTNESS and value > 255: - value = 255 # Convert volume level to 0-100 range if self._attribute == ATTR_MEDIA_VOLUME_LEVEL: if entity_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) is True: - value = 0 - value *= 100 + return 0 + return int(float(value) * 100) + + value = int(value) + if self._attribute == ATTR_BRIGHTNESS and value > 255: + value = 255 return value def get_attribute(self, entity_state: State) -> str | None: @@ -214,9 +231,8 @@ class LinearStrategy(PowerCalculationStrategyInterface): if not self._config.get(CONF_CALIBRATE): if self._source_entity.domain not in ALLOWED_DOMAINS: raise StrategyConfigurationError( - "Entity domain not supported for linear mode. Must be one of: {}, or use the calibrate option".format( - ",".join(ALLOWED_DOMAINS), - ), + "Entity domain not supported for linear mode. " + f"Must be one of: {','.join(ALLOWED_DOMAINS)}, or use the calibrate option", "linear_unsupported_domain", ) if CONF_MAX_POWER not in self._config: @@ -235,7 +251,11 @@ class LinearStrategy(PowerCalculationStrategyInterface): async def get_value_entity(self) -> SourceEntity: """Set the value entity based on the current state.""" - if self._source_entity.domain in (vacuum.DOMAIN, lawn_mower.DOMAIN) and self._attribute is None and self._source_entity.entity_entry: + if ( + self._source_entity.domain in (vacuum.DOMAIN, lawn_mower.DOMAIN) + and self._attribute is None + and self._source_entity.entity_entry + ): # For vacuum cleaner and lawn mower, battery level is a separate entity related_entity = get_related_entity_by_device_class( self._hass, @@ -247,7 +267,7 @@ class LinearStrategy(PowerCalculationStrategyInterface): "No battery entity found for vacuum cleaner", "linear_no_battery_entity", ) - return await create_source_entity(related_entity, self._hass) + return create_source_entity(related_entity, self._hass) return self._value_entity or self._source_entity diff --git a/custom_components/powercalc/strategy/lut.py b/custom_components/powercalc/strategy/lut.py index 6395a8b..5ea2867 100644 --- a/custom_components/powercalc/strategy/lut.py +++ b/custom_components/powercalc/strategy/lut.py @@ -109,7 +109,11 @@ class LutRegistry: supported_modes = self._supported_modes.get(cache_key) if supported_modes is None: supported_modes = set() - for filename in await self._hass.async_add_executor_job(os.listdir, power_profile.get_model_directory()): + filenames = cast( + list[str], + await self._hass.async_add_executor_job(os.listdir, power_profile.get_model_directory()), + ) + for filename in filenames: if filename.endswith((".csv.gz", ".csv")): base_name = filename.split(".", 1)[0] supported_modes.add(LookupMode(base_name)) @@ -290,7 +294,8 @@ class LutStrategy(PowerCalculationStrategyInterface): color_temp = attrs.get(ATTR_COLOR_TEMP_KELVIN) if color_temp is None: _LOGGER.error( - "%s: Could not calculate power. no color temp set. Please check the attributes of your light in the developer tools.", + "%s: Could not calculate power. no color temp set. " + "Please check the attributes of your light in the developer tools.", entity_state.entity_id, ) return None @@ -300,12 +305,17 @@ class LutStrategy(PowerCalculationStrategyInterface): if color_mode == ColorMode.HS: try: original_color_mode = attrs.get(ATTR_COLOR_MODE) - hs = color_temperature_to_hs(attrs[ATTR_COLOR_TEMP_KELVIN]) if original_color_mode == ColorMode.COLOR_TEMP else attrs[ATTR_HS_COLOR] + hs = ( + color_temperature_to_hs(attrs[ATTR_COLOR_TEMP_KELVIN]) + if original_color_mode == ColorMode.COLOR_TEMP + else attrs[ATTR_HS_COLOR] + ) light_setting.hue = int(hs[0] / 360 * 65535) light_setting.saturation = int(hs[1] / 100 * 255) - except Exception: # noqa: BLE001 + except (KeyError, TypeError, ValueError): _LOGGER.error( - "%s: Could not calculate power. no hue/sat set. Please check the attributes of your light in the developer tools.", + "%s: Could not calculate power. no hue/sat set. " + "Please check the attributes of your light in the developer tools.", entity_state.entity_id, ) return None diff --git a/custom_components/powercalc/strategy/multi_switch.py b/custom_components/powercalc/strategy/multi_switch.py index c72fac5..54af0eb 100644 --- a/custom_components/powercalc/strategy/multi_switch.py +++ b/custom_components/powercalc/strategy/multi_switch.py @@ -44,7 +44,8 @@ class MultiSwitchStrategy(PowerCalculationStrategyInterface): async def calculate(self, entity_state: State) -> Decimal | None: if self.known_states is None: self.known_states = { - entity_id: (state.state if (state := self.hass.states.get(entity_id)) else STATE_UNAVAILABLE) for entity_id in self.switch_entities + entity_id: (state.state if (state := self.hass.states.get(entity_id)) else STATE_UNAVAILABLE) + for entity_id in self.switch_entities } if entity_state.entity_id != DUMMY_ENTITY_ID and entity_state.entity_id in self.switch_entities: @@ -60,7 +61,7 @@ class MultiSwitchStrategy(PowerCalculationStrategyInterface): return Decimal(sum(_get_power(state) for state in self.known_states.values())) def get_entities_to_track(self) -> list[str | TrackTemplate]: - return self.switch_entities # type: ignore + return [*self.switch_entities] def can_calculate_standby(self) -> bool: return self.off_power is not None diff --git a/custom_components/powercalc/strategy/playbook.py b/custom_components/powercalc/strategy/playbook.py index 8992333..5f64299 100644 --- a/custom_components/powercalc/strategy/playbook.py +++ b/custom_components/powercalc/strategy/playbook.py @@ -10,8 +10,8 @@ import gzip import logging import os -from homeassistant.const import STATE_OFF -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_OFF +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import ConfigType @@ -64,6 +64,7 @@ class PlaybookStrategy(PowerCalculationStrategyInterface): self._update_callback: Callable[[Decimal], None] = lambda power: None self._start_time: datetime = dt.utcnow() self._cancel_timer: CALLBACK_TYPE | None = None + self._cancel_stop_listener: CALLBACK_TYPE | None = None self._config = config self._repeat: bool = bool(config.get(CONF_REPEAT)) self._autostart: str | None = config.get(CONF_AUTOSTART) @@ -116,6 +117,9 @@ class PlaybookStrategy(PowerCalculationStrategyInterface): if self._cancel_timer is not None: self._cancel_timer() self._cancel_timer = None + if self._cancel_stop_listener is not None: + self._cancel_stop_listener() + self._cancel_stop_listener = None def get_active_playbook(self) -> Playbook | None: """Get running playbook""" @@ -124,41 +128,79 @@ class PlaybookStrategy(PowerCalculationStrategyInterface): @callback def _execute_playbook_entry(self) -> None: """Execute one step of the playbook""" - if self._cancel_timer is not None: - self._cancel_timer() - self._cancel_timer = None + self._cancel_pending_timer() if not self._active_playbook: # pragma: no cover _LOGGER.error("Could not execute next playbook entry. No active playbook") return - queue = self._active_playbook.queue - if len(queue) == 0: - if self._repeat: - _LOGGER.debug("Playbook %s repeating", self._active_playbook.key) - self._start_time = dt.utcnow() - queue.reset() - self._execute_playbook_entry() - return - - _LOGGER.debug("Playbook %s completed", self._active_playbook.key) - self._active_playbook = None + playbook = self._active_playbook + if self._complete_or_repeat_playbook(playbook): return - entry = queue.dequeue() + entry = playbook.queue.dequeue() + self._schedule_playbook_entry_update(playbook, entry) + + @callback + def _cancel_pending_timer(self) -> None: + if self._cancel_timer is not None: + self._cancel_timer() + self._cancel_timer = None + + @callback + def _complete_or_repeat_playbook(self, playbook: Playbook) -> bool: + queue = playbook.queue + if len(queue) != 0: + return False + + if self._repeat: + _LOGGER.debug("Playbook %s repeating", playbook.key) + self._start_time = dt.utcnow() + queue.reset() + self._execute_playbook_entry() + return True + + _LOGGER.debug("Playbook %s completed", playbook.key) + self._active_playbook = None + return True + + @callback + def _schedule_playbook_entry_update(self, playbook: Playbook, entry: PlaybookEntry) -> None: + """Schedule the next playbook power update.""" @callback def _update_power(date_time: datetime) -> None: + active_playbook = self._active_playbook + if active_playbook is None: # pragma: no cover + return self._power = entry.power - _LOGGER.debug("playbook %s: Update power %.2f", self._active_playbook.key, self._power) # type: ignore + _LOGGER.debug("playbook %s: Update power %.2f", active_playbook.key, self._power) self._update_callback(self._power) - # Schedule next update self._execute_playbook_entry() + @callback + def _cancel_pending_updates_on_stop(_: datetime) -> None: + self._cancel_pending_timer() + if self._cancel_stop_listener is not None: + self._cancel_stop_listener() + self._cancel_stop_listener = None + self._active_playbook = None + + if self._cancel_stop_listener is not None: + self._cancel_stop_listener() + self._cancel_stop_listener = self._hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + _cancel_pending_updates_on_stop, + ) + # Schedule update in the future self._cancel_timer = async_track_point_in_time( self._hass, - _update_power, + HassJob( + _update_power, + name=f"powercalc playbook {playbook.key}", + cancel_on_shutdown=True, + ), self._start_time + timedelta(seconds=entry.time), ) @@ -167,20 +209,20 @@ class PlaybookStrategy(PowerCalculationStrategyInterface): if playbook_id in self._loaded_playbooks: return self._loaded_playbooks[playbook_id] - playbooks: dict[str, str] = self._config.get(CONF_PLAYBOOKS) # type: ignore + playbooks: dict[str, str] = dict(self._config.get(CONF_PLAYBOOKS) or {}) if playbook_id not in playbooks: raise StrategyConfigurationError( f"Playbook with id {playbook_id} not defined in playbooks config", ) file_path = os.path.join(self._playbook_directory, playbooks[playbook_id]) - if not (os.path.exists(file_path) or os.path.exists(f"{file_path}.gz")): - raise StrategyConfigurationError( - f"Playbook file '{file_path}' does not exist", - ) def _load_playbook_entries() -> list[PlaybookEntry]: """Load playbook entries from a CSV file, with support for gzipped files""" + if not (os.path.exists(file_path) or os.path.exists(f"{file_path}.gz")): + raise StrategyConfigurationError( + f"Playbook file '{file_path}' does not exist", + ) actual_path = file_path if os.path.exists(file_path) else f"{file_path}.gz" open_func = gzip.open if actual_path.endswith(".gz") else open with open_func(actual_path, mode="rt") as csv_file: diff --git a/custom_components/powercalc/strategy/strategy_interface.py b/custom_components/powercalc/strategy/strategy_interface.py index 1b66560..38c957a 100644 --- a/custom_components/powercalc/strategy/strategy_interface.py +++ b/custom_components/powercalc/strategy/strategy_interface.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable from decimal import Decimal from homeassistant.core import HomeAssistant, State @@ -28,5 +29,8 @@ class PowerCalculationStrategyInterface: """Return if this strategy is enabled based on entity state.""" return True + def set_update_callback(self, update_callback: Callable[[Decimal], None]) -> None: + """Register update callback to allow strategy to push power updates.""" + async def on_start(self, hass: HomeAssistant) -> None: """Called after HA has started""" diff --git a/custom_components/powercalc/strategy/wled.py b/custom_components/powercalc/strategy/wled.py index 7d46168..3d2e3cd 100644 --- a/custom_components/powercalc/strategy/wled.py +++ b/custom_components/powercalc/strategy/wled.py @@ -48,7 +48,11 @@ class WledStrategy(PowerCalculationStrategyInterface): self._estimated_current_entity: str | None = None async def calculate(self, entity_state: State) -> Decimal | None: - light_state = entity_state if entity_state.entity_id == self._light_entity.entity_id else self._hass.states.get(self._light_entity.entity_id) + light_state = ( + entity_state + if entity_state.entity_id == self._light_entity.entity_id + else self._hass.states.get(self._light_entity.entity_id) + ) if light_state.state in OFF_STATES and self._standby_power: return self._standby_power @@ -72,7 +76,7 @@ class WledStrategy(PowerCalculationStrategyInterface): self._power_factor, ) power = float(entity_state.state) / 1000 * self._voltage * self._power_factor - return await evaluate_power(power) + return evaluate_power(power) async def find_estimated_current_entity(self) -> str: entity_reg = entity_registry.async_get(self._hass) @@ -86,7 +90,9 @@ class WledStrategy(PowerCalculationStrategyInterface): if entity: return entity - raise StrategyConfigurationError("No estimated current entity found. Probably brightness limiter not enabled. See documentation") + raise StrategyConfigurationError( + "No estimated current entity found. Probably brightness limiter not enabled. See documentation", + ) def get_entities_to_track(self) -> list[str | TrackTemplate]: if self._estimated_current_entity: diff --git a/custom_components/powercalc/translations/cz.json b/custom_components/powercalc/translations/cz.json index d8f0e76..2209030 100644 --- a/custom_components/powercalc/translations/cz.json +++ b/custom_components/powercalc/translations/cz.json @@ -1,7 +1,7 @@ { "common": { - "source_entity": "Source entity", - "source_device": "Source device", + "source_entity": "Zdrojová entita", + "source_device": "Zdrojové zařízení", "remarks_smart_switch": "Tento profil poskytuje pouze hodnoty napájení v pohotovostním režimu. \n Pokud máte spotřebič, který zapnutí spotřebovává pevně dané množství energie, můžete zadat tuto hodnotu v dalším kroku v nastavení", "remarks_smart_dimmer": "Tento profil poskytuje pouze hodnoty spotřeby energie v pohotovostním režimu. \n V dalším kroku můžete volitelně zadat hodnoty připojeného světla" }, @@ -10,9 +10,9 @@ "already_configured": "Senzor je již nastaven, zadejte unikátní ID" }, "error": { - "daily_energy_mandatory": "You must supply at least one of Value or Value template", - "entity_mandatory": "Selecting an entity is required for any strategy other than playbook", - "fixed_mandatory": "You must supply at least one of Power, Power template or States power", + "daily_energy_mandatory": "Musíte zadat alespoň jednu z možností „Hodnota“ nebo „Šablona hodnoty“", + "entity_mandatory": "Pro všechny strategie kromě playbook je nutné vybrat entitu", + "fixed_mandatory": "Musíte zadat alespoň jednu z následujících možností: Power, šablonu Power nebo States power", "fixed_states_power_only": "This entity can only work with 'states_power' not 'power'", "group_mandatory": "You must define at least subgroups or power and energy-entities", "linear_mandatory": "You must supply at least one of max_power or calibrate", @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Create utility meters", + "daily_energy_value": "Daily energy value", "name": "Název", "on_time": "V čase", "start_time": "Start time", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Power", - "power_template": "Power template", - "states_power": "States power" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Pevná hodnota příkonu ve wattech, když je entita zapnutá", - "power_template": "Šablona bude vyhodnocena pro získání hodnoty příkonu ve wattech", - "states_power": "One 'state: power' pair on each row" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Definujte fixní hodnotu příkonu pro vaši entitu. Případně můžete definovat hodnotu příkonu pro jednotlivé stavy. Například:\n\n`playing: 8.3`\n`paused: 2.25`", "title": "Fixed config" @@ -290,6 +287,7 @@ "attribute": "Specify the attribute. When left empty will be brightness for lights and percentage for fans", "calibrate": "Put a calibration value on each line. Example\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Lineární konfigurace" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Power for a single switch when turned on", "power_off": "Power for a single switch when turned off" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Multi switch config" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Toggle when you want to keep repeating the playbook after it completes", "states_trigger": "Trigger a playbook based on a state change. Example\n\nplaying: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Playbook config" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Daily energy options", "data": { + "daily_energy_value": "Daily energy value", "name": "Name", "on_time": "On time", "start_time": "Start time", @@ -571,16 +572,11 @@ "fixed": { "title": "Fixed options", "data": { - "power": "Power", - "power_template": "Power template", - "self_usage_included": "Self usage included", - "states_power": "States power" + "fixed_value": "Fixed value", + "self_usage_included": "Self usage included" }, "data_description": { - "power": "A fixed power value in Watts when the entity is ON", - "power_template": "Template will be evaluated to get the power value in W", - "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself.", - "states_power": "One 'state: power' pair on each row, see example above" + "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Start execution of a playbook.", @@ -860,6 +871,10 @@ }, "name": "Change GUI config" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Get current running playbook", "name": "Get active playbook" diff --git a/custom_components/powercalc/translations/de.json b/custom_components/powercalc/translations/de.json index 6e68fc9..7ed5828 100644 --- a/custom_components/powercalc/translations/de.json +++ b/custom_components/powercalc/translations/de.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Utilitymeter erstellen", + "daily_energy_value": "Daily energy value", "name": "Name", "on_time": "Dauer", "start_time": "Startzeit", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Leistung", - "power_template": "Leistung-Template", - "states_power": "Leistung je Zustand" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Ein fester Leistungswert in Watt, wenn die Einheit eingeschaltet ist", - "power_template": "Template wird ausgewertet, um den Leistungswert in W zu erhalten", - "states_power": "Ein 'state: power' Paar in jeder Zeile" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Definieren Sie einen festen Leistungswert für Ihre Entität. Alternativ können Sie auch einen Leistungswert pro State definieren. Zum Beispiel:\n\n`playing: 8.3`\n`paused: 2.25`", "title": "Fixed Konfiguration" @@ -290,6 +287,7 @@ "attribute": "Geben Sie das Attribut an. Wenn es leer gelassen wird, wird Helligkeit für Lichter und Prozentsatz für Ventilatoren verwendet", "calibrate": "Geben Sie in jeder Zeile einen Kalibrierungswert an. Beispiel\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Linear Konfiguration" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Power for a single switch when turned on", "power_off": "Power for a single switch when turned off" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Multi switch config" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Umschalten, wenn Sie das Playbook nach Abschluss wiederholen möchten", "states_trigger": "Auslösen eines Playbooks auf der Grundlage einer Zustandsänderung. Beispiel" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Playbook-Konfiguration" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Tägliche Energieeinstellungen", "data": { + "daily_energy_value": "Daily energy value", "name": "Name", "on_time": "Dauer", "start_time": "Startzeit", @@ -571,16 +572,11 @@ "fixed": { "title": "Feste Einstellungen", "data": { - "power": "Leistung", - "power_template": "Leistung-Template", - "self_usage_included": "Eigenverbrauch inklusive", - "states_power": "Leistung je Zustand" + "fixed_value": "Fixed value", + "self_usage_included": "Eigenverbrauch inklusive" }, "data_description": { - "power": "Ein fester Leistungswert in Watt, wenn die Entität eingeschaltet ist", - "power_template": "Template wird ausgewertet, um den Leistungswert in W zu erhalten", - "self_usage_included": "Ob der Leistungswert die vom intelligenten Schalter selbst verbrauchte Leistung enthält.", - "states_power": "Ein 'state: power' Paar in jeder Zeile, siehe Beispiel oben" + "self_usage_included": "Ob der Leistungswert die vom intelligenten Schalter selbst verbrauchte Leistung enthält." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Starten Sie die Ausführung eines Playbooks.", @@ -860,6 +871,10 @@ }, "name": "GUI-Konfiguration ändern" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Aktuelles laufendes Playbook abrufen", "name": "Aktuelles Playbook abrufen" diff --git a/custom_components/powercalc/translations/en.json b/custom_components/powercalc/translations/en.json index 5ca30c3..39572ae 100644 --- a/custom_components/powercalc/translations/en.json +++ b/custom_components/powercalc/translations/en.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Create utility meters", + "daily_energy_value": "Daily energy value", "name": "Name", "on_time": "On time", "start_time": "Start time", @@ -87,16 +88,12 @@ }, "fixed": { "data": { - "power": "Power", - "power_template": "Power template", - "states_power": "States power" + "fixed_value": "Power value" }, "data_description": { - "power": "A fixed power value in Watts when the entity is ON", - "power_template": "Template will be evaluated to get the power value in W", - "states_power": "One 'state: power' pair on each row" + "fixed_value": "Power value in Watts when the entity is ON" }, - "description": "Define a fixed power value for your entity. Alternatively you can define a power value per state. For example:\n\n`playing: 8.3`\n`paused: 2.25`", + "description": "Define a fixed power value for your entity. See the [documentation]({docs_uri}) for more info", "title": "Fixed config" }, "global_configuration": { @@ -290,6 +287,7 @@ "attribute": "Specify the attribute. When left empty will be brightness for lights, volume for speakers, and percentage for fans", "calibrate": "Put a calibration value on each line. Example\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Linear config" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Power for a single switch when turned on", "power_off": "Power for a single switch when turned off" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Multi switch config" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Toggle when you want to keep repeating the playbook after it completes", "states_trigger": "Trigger a playbook based on a state change. Example\n\nplaying: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Playbook config" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Daily energy options", "data": { + "daily_energy_value": "Daily energy value", "name": "Name", "on_time": "On time", "start_time": "Start time", @@ -571,16 +572,11 @@ "fixed": { "title": "Fixed options", "data": { - "power": "Power", - "power_template": "Power template", - "self_usage_included": "Self usage included", - "states_power": "States power" + "fixed_value": "Fixed value", + "self_usage_included": "Self usage included" }, "data_description": { - "power": "A fixed power value in Watts when the entity is ON", - "power_template": "Template will be evaluated to get the power value in W", - "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself.", - "states_power": "One 'state: power' pair on each row" + "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Fixed value", + "value_template": "Template" + } + }, + "fixed_value": { + "choices": { + "power": "Fixed value", + "power_template": "Template", + "states_power": "State mapping" + } + } + }, "services": { "activate_playbook": { "description": "Start execution of a playbook.", @@ -860,6 +871,10 @@ }, "name": "Change GUI config" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Get current running playbook", "name": "Get active playbook" diff --git a/custom_components/powercalc/translations/es.json b/custom_components/powercalc/translations/es.json index 2ba61b5..aa17a5f 100644 --- a/custom_components/powercalc/translations/es.json +++ b/custom_components/powercalc/translations/es.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Crear contadores eléctricos", + "daily_energy_value": "Daily energy value", "name": "Nombre", "on_time": "Tiempo encendido", "start_time": "Hora de inicio", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Potencia", - "power_template": "Plantilla de potencia", - "states_power": "Estados de potencia" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Un valor de potencia fijo en Vatios (W) cuando la entidad está ENCENDIDA", - "power_template": "La plantilla será evaluada para obtener el valor de potencia en Vatios (W)", - "states_power": "Un par (\"estado: potencia\") en cada fila" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Definir un valor de potencia fijo para tu entidad. Alternativamente, puedes definir un valor de potencia por estado. Por ejemplo: \n\n`playing: 8.3`\n`paused: 2.25`", "title": "Configuración fija" @@ -290,6 +287,7 @@ "attribute": "Especifique el atributo. Cuando se deja vacío será el brillo para las luces y porcentaje para los ventiladores", "calibrate": "Pon un valor de calibración en cada línea. Ejemplo\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Configuración lineal" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Potencia para un solo interruptor cuando se enciende", "power_off": "Potencia para un solo interruptor cuando se apaga" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Configuración multiinterruptor" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Alternar cuando quieras seguir repitiendo la reproducción una vez completada", "states_trigger": "Dispara una reproducción basada en un cambio de estado. Ejemplo\n\nreproduciendo: programa1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Configuración de Reproducciones" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Opciones de energía diarias", "data": { + "daily_energy_value": "Daily energy value", "name": "Nombre", "on_time": "Tiempo encendido", "start_time": "Hora de inicio", @@ -571,16 +572,11 @@ "fixed": { "title": "Opciones fijas", "data": { - "power": "Potencia", - "power_template": "Plantilla de potencia", - "self_usage_included": "Uso propio incluido", - "states_power": "Estados de potencia" + "fixed_value": "Fixed value", + "self_usage_included": "Uso propio incluido" }, "data_description": { - "power": "Un valor de potencia fijo en Vatios (W) cuando la entidad está ENCENDIDA", - "power_template": "La plantilla será evaluada para obtener el valor de potencia en Vatios (W)", - "self_usage_included": "Si el valor de potencia incluye la energía consumida por el interruptor inteligente mismo.", - "states_power": "Un par \"estado: potencia\" en cada fila" + "self_usage_included": "Si el valor de potencia incluye la energía consumida por el interruptor inteligente mismo." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Iniciar la ejecución de una reproducción.", @@ -860,6 +871,10 @@ }, "name": "Cambio configuración gráfica" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Obtener reproducción ejecutándose actualmente", "name": "Obtener reproducción activa" diff --git a/custom_components/powercalc/translations/fr.json b/custom_components/powercalc/translations/fr.json index 5ab0a0b..204ff6f 100644 --- a/custom_components/powercalc/translations/fr.json +++ b/custom_components/powercalc/translations/fr.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Créer des compteurs de services publics", + "daily_energy_value": "Daily energy value", "name": "Nom", "on_time": "Temps de fonctionnement", "start_time": "Heure de début", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Puissance", - "power_template": "Modèle de puissance", - "states_power": "Etat puissance" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Une valeur de puissance fixe en Watts lorsque l'entité est ON", - "power_template": "Le modèle sera évalué pour obtenir la valeur de puissance en W", - "states_power": "Une paire 'état : alimentation' sur chaque ligne" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Définissez une valeur de puissance fixe pour votre entité. Vous pouvez également définir une valeur de puissance par état. Par exemple :\n\n`lecture : 8.3`\n`pause : 2.25`", "title": "Configuration fixe" @@ -290,6 +287,7 @@ "attribute": "Spécifiez l'attribut. Lorsqu'il est laissé vide, la luminosité des lumières et le pourcentage des ventilateurs seront affichés.", "calibrate": "Mettez une valeur d'étalonnage sur chaque ligne. Exemple\n\n1 : 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Configuration linéaire" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Power for a single switch when turned on", "power_off": "Power for a single switch when turned off" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Multi switch config" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Basculer lorsque vous souhaitez continuer à répéter le playbook une fois qu'il est terminé", "states_trigger": "Trigger a playbook based on a state change. Example\n\nplaying: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Configuration Playbook" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Daily energy options", "data": { + "daily_energy_value": "Daily energy value", "name": "Nom", "on_time": "Temps de fonctionnement", "start_time": "Start time", @@ -571,16 +572,11 @@ "fixed": { "title": "Fixed options", "data": { - "power": "Puissance", - "power_template": "Modèle de puissance", - "self_usage_included": "Self usage included", - "states_power": "Etats de puissance" + "fixed_value": "Fixed value", + "self_usage_included": "Self usage included" }, "data_description": { - "power": "A fixed power value in Watts when the entity is ON", - "power_template": "Le modèle sera évalué pour obtenir la valeur de puissance en W", - "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself.", - "states_power": "Une paire 'état : alimentation' sur chaque ligne" + "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Démarrer l'exécution d'un playbook.", @@ -860,6 +871,10 @@ }, "name": "Calibrate utility meter" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Get current running playbook", "name": "Get active playbook" diff --git a/custom_components/powercalc/translations/it.json b/custom_components/powercalc/translations/it.json index 6d504a1..b0bc6e6 100644 --- a/custom_components/powercalc/translations/it.json +++ b/custom_components/powercalc/translations/it.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Crea contatori (utility meters)", + "daily_energy_value": "Daily energy value", "name": "Nome", "on_time": "In tempo", "start_time": "Orario d'inizio", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Potenza", - "power_template": "Template di alimentazione", - "states_power": "Potenza degli Stati" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Un valore di potenza fissa in watt quando l'entità è attiva", - "power_template": "Il modello sarà valutato per ottenere il valore di potenza in W", - "states_power": "One 'state: power' pair on each row, see example above" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Definisci un valore di potenza fisso per la tua entità. In alternativa puoi definire un valore di potenza per stato. Per esempio:\n\n`playing: 8.3`\n`pausa: 2.25`", "title": "Configurazione fissa" @@ -290,6 +287,7 @@ "attribute": "Specifica l'attributo. Quando lasciato vuoto sarà luminosità per le luci e percentuale per i ventilatori", "calibrate": "Inserire un valore di calibrazione su ogni riga. Esempio\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Configurazione lineare" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Potenza di un singolo interruttore quando è acceso", "power_off": "Potenza di un singolo interruttore quando è acceso" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Configurazione multi-interruttore" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Attiva/disattiva quando si desidera continuare a ripetere il playbook dopo che è stato completato", "states_trigger": "Attiva un playbook in base a un cambio di stato. Esempio\n\nriproduzione: programma1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Configurazione Playbook" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Opzioni energetiche giornaliere", "data": { + "daily_energy_value": "Daily energy value", "name": "Nome", "on_time": "tempo stato ON", "start_time": "Tempo di inizio", @@ -571,16 +572,11 @@ "fixed": { "title": "Opzioni corrette", "data": { - "power": "Potenza", - "power_template": "Power template", - "self_usage_included": "Self usage included", - "states_power": "States power" + "fixed_value": "Fixed value", + "self_usage_included": "Self usage included" }, "data_description": { - "power": "Un valore di potenza fissa in watt quando l'entità è attiva", - "power_template": "Il modello sarà valutato per ottenere il valore di potenza in W", - "self_usage_included": "Il valore di potenza include l'energia consumata dallo smart switch stesso.", - "states_power": "One 'state: power' pair on each row, see example above" + "self_usage_included": "Il valore di potenza include l'energia consumata dallo smart switch stesso." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Avvia l'esecuzione di un playbook.", @@ -860,6 +871,10 @@ }, "name": "Cambia configurazione GUI" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Get current running playbook", "name": "Ottieni playbook attivo" diff --git a/custom_components/powercalc/translations/nl.json b/custom_components/powercalc/translations/nl.json index 5eb097e..d9736c0 100644 --- a/custom_components/powercalc/translations/nl.json +++ b/custom_components/powercalc/translations/nl.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Creëer utiliteit meters", + "daily_energy_value": "Daily energy value", "name": "Naam", "on_time": "Tijd aan per dag", "start_time": "Begintijd", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Vermogen", - "power_template": "Vermogen template", - "states_power": "Vermogen per status" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Een vastgestelde vermogen in Watt wanneer de gekoppelde entiteit AAN is", - "power_template": "Template die geëvalueerd wordt om het vermogen (W) te bepalen", - "states_power": "Eén 'status: vermogen' paar op elke rij" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Definieer een vast vermogen (in W) voor je entiteit. Eventueel kan je ook een vermogen per status instellen. Bijvoorbeeld:\n\n`playing: 8.3`\n`paused: 2.25`", "title": "Gefixeerde configuratie" @@ -290,6 +287,7 @@ "attribute": "Specificeer een attribuut. Wanneer je dit leeg laat dan wordt helderheid gebruikt voor verlichting en percentage voor ventilatoren", "calibrate": "Kalibratiewaarde op iedere regel. Voorbeeld\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Lineaire configuratie" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Vermogen voor één schakelaar wanneer ingeschakeld", "power_off": "Vermogen voor één enkele schakelaar wanneer uitgeschakeld" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Multi switch configuratie" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Zet aan wanneer je wilt dat het playbook opnieuw wordt afgespeeld nadat het is beëindigd", "states_trigger": "Start een bepaald playbook gebaseerd op status. Voorbeeld\n\nplaying: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Playbook configuratie" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Dagelijkse energie opties", "data": { + "daily_energy_value": "Daily energy value", "name": "Naam", "on_time": "Tijd aan per dag", "start_time": "Begintijd", @@ -571,16 +572,11 @@ "fixed": { "title": "Fixed opties", "data": { - "power": "Vermogen", - "power_template": "Vermogen template", - "self_usage_included": "Eigen verbruik inbegrepen", - "states_power": "Vermogen per status" + "fixed_value": "Fixed value", + "self_usage_included": "Eigen verbruik inbegrepen" }, "data_description": { - "power": "Een vastgestelde vermogen in Watt wanneer de gekoppelde entiteit AAN is", - "power_template": "Template die geëvalueerd wordt om het vermogen (W) te bepalen", - "self_usage_included": "Of het opgegeven vermogen ook het energieverbruik omvat dat door de slimme schakelaar zelf wordt verbruikt.", - "states_power": "Eén 'status: vermogen' paar op elke rij" + "self_usage_included": "Of het opgegeven vermogen ook het energieverbruik omvat dat door de slimme schakelaar zelf wordt verbruikt." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Start verwerking van een playbook.", @@ -860,6 +871,10 @@ }, "name": "GUI configuratie wijzigen" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Laad huidige actieve playbook", "name": "Laad actieve playbook" diff --git a/custom_components/powercalc/translations/pl.json b/custom_components/powercalc/translations/pl.json index 20c0e0d..2096323 100644 --- a/custom_components/powercalc/translations/pl.json +++ b/custom_components/powercalc/translations/pl.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Utwórz liczniki mediów (utility meter)", + "daily_energy_value": "Daily energy value", "name": "Nazwa", "on_time": "Sumaryczny czas włączenia", "start_time": "Moment uruchomienia", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Moc", - "power_template": "Szablon mocy", - "states_power": "Moce stanów" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Stała wartość mocy w watach, gdy encja jest WŁĄCZONA", - "power_template": "Szablon zostanie obliczony w celu uzyskania wartości mocy w watach", - "states_power": "Jedna para 'stan: moc' w każdym rzędzie" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Określ stałą wartość mocy dla swojej encji. Alternatywnie możesz zdefiniować wartość mocy dla każdego stanu. Na przykład:\n\n`odtwarzanie: 8.3`\n`pauza: 2.25`", "title": "Konfiguracja stała" @@ -290,6 +287,7 @@ "attribute": "Określ Atrybut. Gdy pole pozostanie puste, będzie to jasność dla świateł i procent dla wentylatorów", "calibrate": "Umieść wartość kalibracji w każdym wierszu. Przykład\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Konfiguracja liniowa" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Moc dla pojedynczego przełącznika, kiedy włączony", "power_off": "Moc dla pojedynczego przełącznika, kiedy wyłączony" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Konfiguracja wieloprzełącznika" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Przełącz, jeśli chcesz powtarzać playbook po jego zakończeniu", "states_trigger": "Wywołaj playbook na podstawie zmiany stanu. Przykład\n\nodtwarzanie: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Konfiguracja Playbook'ów" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Dzienne opcje energii", "data": { + "daily_energy_value": "Daily energy value", "name": "Nazwa", "on_time": "Sumaryczny czas włączenia", "start_time": "Czas rozpoczęcia", @@ -571,16 +572,11 @@ "fixed": { "title": "Opcje stałe", "data": { - "power": "Moc", - "power_template": "Szablon mocy", - "self_usage_included": "Zawarte własne użycie", - "states_power": "Moce stanów" + "fixed_value": "Fixed value", + "self_usage_included": "Zawarte własne użycie" }, "data_description": { - "power": "Stała wartość mocy w watach, gdy jednostka jest włączona", - "power_template": "Szablon zostanie obliczony w celu uzyskania wartości mocy w W", - "self_usage_included": "Czy wartość mocy obejmuje moc zużywaną przez sam inteligentny przełącznik.", - "states_power": "Jedna para 'stan: moc' w każdym rzędzie" + "self_usage_included": "Czy wartość mocy obejmuje moc zużywaną przez sam inteligentny przełącznik." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Rozpocznij wykonywanie playbooka.", @@ -860,6 +871,10 @@ }, "name": "Zmień konfigurację GUI" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Pobierz bieżący playbook", "name": "Pobierz aktywny playbook" diff --git a/custom_components/powercalc/translations/pt-BR.json b/custom_components/powercalc/translations/pt-BR.json index d9fdf54..b4cbbef 100644 --- a/custom_components/powercalc/translations/pt-BR.json +++ b/custom_components/powercalc/translations/pt-BR.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Criar medidores de utilidade", + "daily_energy_value": "Daily energy value", "name": "Nome", "on_time": "Na hora", "start_time": "Hora de início", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Potência", - "power_template": "Modelo de potência", - "states_power": "Potência dos estados" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Um valor de potência fixo em Watts quando a entidade está LIGADA", - "power_template": "O modelo será avaliado para obter o valor da potência em W", - "states_power": "Um par 'estado: poder' em cada linha, veja o exemplo acima" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Defina um valor de potência fixo para sua entidade. Alternativamente, você pode definir um valor de potência por estado. Por exemplo:\n\n`playing: 8.3`\n`paused: 2.25`", "title": "Configuração fixa" @@ -290,6 +287,7 @@ "attribute": "Especifique o atributo. Quando deixado vazio, haverá brilho para luzes e porcentagem para ventiladores", "calibrate": "Coloque um valor de calibração em cada linha. Exemplo\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Configuração linear" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Potência para um único interruptor quando ligado", "power_off": "Potência para um único interruptor quando desligado" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Opções de multi-interruptor" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Alterar quando você quiser continuar repetindo o playbook após ele terminar", "states_trigger": "Alterar o playbook de acordo com o estado. Exemplo\n\nplaying: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Configuração do Playbook" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Opções de consumo diário", "data": { + "daily_energy_value": "Daily energy value", "name": "Nome", "on_time": "Na hora", "start_time": "Hora de início", @@ -571,16 +572,11 @@ "fixed": { "title": "Opções de Fixo", "data": { - "power": "Potência", - "power_template": "Modelo de potência", - "self_usage_included": "Auto uso incluído", - "states_power": "Potência dos estados" + "fixed_value": "Fixed value", + "self_usage_included": "Auto uso incluído" }, "data_description": { - "power": "Um valor de potência fixo em Watts quando a entidade está LIGADA", - "power_template": "O modelo será avaliado para obter o valor da potência em W", - "self_usage_included": "Se o valor de potência inclui a energia consumida pelo próprio interruptor inteligente.", - "states_power": "Um par 'estado: potência' em cada linha, veja o exemplo acima" + "self_usage_included": "Se o valor de potência inclui a energia consumida pelo próprio interruptor inteligente." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Iniciar a execução de um playbook.", @@ -860,6 +871,10 @@ }, "name": "Calibrate utility meter" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Obter o atual playbook em execução", "name": "Obter playbook ativo" diff --git a/custom_components/powercalc/translations/pt.json b/custom_components/powercalc/translations/pt.json index 8a5ae95..58a10af 100644 --- a/custom_components/powercalc/translations/pt.json +++ b/custom_components/powercalc/translations/pt.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Create utility meters", + "daily_energy_value": "Daily energy value", "name": "Nome", "on_time": "Na hora", "start_time": "Hora de início", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Potência", - "power_template": "Modelo de potência", - "states_power": "Potência dos Estados" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Um valor de potência fixo em Watts quando a entidade está LIGADA", - "power_template": "O modelo será avaliado para obter o valor da potência em W", - "states_power": "Um par 'estado: potência' em cada linha, veja o exemplo acima" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Defina um valor de potência fixo para sua entidade. Alternativamente, você pode definir um valor de energia por estado. Por exemplo:\n\n`reproduzindo: 8.3`\n`pausado: 2.25`", "title": "Configuração fixa" @@ -290,6 +287,7 @@ "attribute": "Especifique o atributo. Quando deixado vazio, haverá brilho para luzes e percentagem para ventoinhas", "calibrate": "Coloque um valor de calibração em cada linha. Exemplo\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Configuração linear" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Power for a single switch when turned on", "power_off": "Power for a single switch when turned off" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Multi switch config" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Alternar se pretende que a sequência reinicie e repita após terminar", "states_trigger": "Trigger a playbook based on a state change. Example\n\nplaying: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Configuração de Sequência" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Daily energy options", "data": { + "daily_energy_value": "Daily energy value", "name": "Nome", "on_time": "Na hora", "start_time": "Start time", @@ -571,16 +572,11 @@ "fixed": { "title": "Fixed options", "data": { - "power": "Potência", - "power_template": "Modelo de energia", - "self_usage_included": "Self usage included", - "states_power": "Potência dos Estados" + "fixed_value": "Fixed value", + "self_usage_included": "Self usage included" }, "data_description": { - "power": "A fixed power value in Watts when the entity is ON", - "power_template": "O modelo será avaliado para obter o valor da potência em W", - "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself.", - "states_power": "Um par 'estado: ptência' em cada linha, veja o exemplo acima" + "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Iniciar execução de uma Sequência.", @@ -860,6 +871,10 @@ }, "name": "Calibrate utility meter" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Get current running playbook", "name": "Get active playbook" diff --git a/custom_components/powercalc/translations/ro.json b/custom_components/powercalc/translations/ro.json index 15add71..28ef62f 100644 --- a/custom_components/powercalc/translations/ro.json +++ b/custom_components/powercalc/translations/ro.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Creați contoare", + "daily_energy_value": "Daily energy value", "name": "Nume", "on_time": "Timp pornit", "start_time": "Timpul de pornire", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Putere", - "power_template": "Sablon de putere", - "states_power": "Puterea starilor" + "fixed_value": "Fixed value" }, "data_description": { - "power": "O valoare fixă a puterii în wați când entitatea este PORNITĂ", - "power_template": "Șablonul va fi evaluat pentru a obține valoarea puterii în W", - "states_power": "O pereche 'stare: putere' pe fiecare rând, vezi exemplul de mai sus" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Definiți o valoare fixă a puterii pentru entitatea dvs. Alternativ, puteți defini o valoare de putere per stare. De exemplu:\n\n`redare: 8,3`\n`în pauză: 2,25`", "title": "Configurație fixă" @@ -290,6 +287,7 @@ "attribute": "Specificați atributul. Când este lăsat gol, va fi luminozitate pentru lumini și procent pentru ventilatoare", "calibrate": "Pune o valoare de calibrare pe fiecare linie. Exemplu\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Configurație liniară" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Power for a single switch when turned on", "power_off": "Power for a single switch when turned off" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Multi switch config" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Comutați când doriți să continuați să repetați manualul după finalizarea acestuia", "states_trigger": "Trigger a playbook based on a state change. Example\n\nplaying: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Configurare Playbook" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Daily energy options", "data": { + "daily_energy_value": "Daily energy value", "name": "Nume", "on_time": "Timp pornit", "start_time": "Start time", @@ -571,16 +572,11 @@ "fixed": { "title": "Fixed options", "data": { - "power": "Putere", - "power_template": "Șablon de putere", - "self_usage_included": "Self usage included", - "states_power": "Putere de stare" + "fixed_value": "Fixed value", + "self_usage_included": "Self usage included" }, "data_description": { - "power": "A fixed power value in Watts when the entity is ON", - "power_template": "Șablonul va fi evaluat pentru a obține valoarea puterii în W", - "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself.", - "states_power": "O pereche 'stare: putere' pe fiecare rând, vezi exemplul de mai sus" + "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Începeți execuția unui playbook.", @@ -860,6 +871,10 @@ }, "name": "Schimbați configurația GUI" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Get current running playbook", "name": "Get active playbook" diff --git a/custom_components/powercalc/translations/ru.json b/custom_components/powercalc/translations/ru.json index 986b8aa..1a81625 100644 --- a/custom_components/powercalc/translations/ru.json +++ b/custom_components/powercalc/translations/ru.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Создать счётчики", + "daily_energy_value": "Daily energy value", "name": "Название", "on_time": "Время работы", "start_time": "Время начала", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Мощность", - "power_template": "Шаблон мощности", - "states_power": "Мощность по состояниям" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Фиксированное значение мощности в ваттах, когда объект включён", - "power_template": "Шаблон для вычисления значения мощности в ваттах", - "states_power": "Пара 'состояние: мощность' на каждой строке, см. [документацию](https://docs.powercalc.nl/strategies/fixed/#power-per-state)" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Определите фиксированное значение мощности для вашего объекта. Альтернативно можно указать мощность для каждого состояния. Например:\n\n`playing: 8.3`\n`paused: 2.25`", "title": "Фиксированная конфигурация" @@ -290,6 +287,7 @@ "attribute": "Укажите атрибут. Если оставить пустым, будет использоваться яркость для светильников, громкость для колонок и процент для вентиляторов", "calibrate": "Укажите калибровочное значение на каждой строке. Пример\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Линейная конфигурация" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Мощность одного выключателя во включённом состоянии", "power_off": "Мощность одного выключателя в выключенном состоянии" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Конфигурация мультивыключателя" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Включите, если хотите повторять сценарий после его завершения", "states_trigger": "Запуск сценария по изменению состояния. Пример\n\nplaying: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Конфигурация сценариев" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Параметры суточной энергии", "data": { + "daily_energy_value": "Daily energy value", "name": "Название", "on_time": "Время работы", "start_time": "Время начала", @@ -571,16 +572,11 @@ "fixed": { "title": "Фиксированные параметры", "data": { - "power": "Мощность", - "power_template": "Шаблон мощности", - "self_usage_included": "Включено собственное потребление", - "states_power": "Мощность по состояниям" + "fixed_value": "Fixed value", + "self_usage_included": "Включено собственное потребление" }, "data_description": { - "power": "Фиксированное значение мощности в ваттах, когда объект включён", - "power_template": "Шаблон для вычисления значения мощности в ваттах", - "self_usage_included": "Включено ли в значение мощности потребление самого умного выключателя", - "states_power": "Пара 'состояние: мощность' на каждой строке, см. [документацию](https://docs.powercalc.nl/strategies/fixed/#power-per-state)" + "self_usage_included": "Включено ли в значение мощности потребление самого умного выключателя" } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Запустить выполнение сценария.", @@ -860,6 +871,10 @@ }, "name": "Изменить конфигурацию GUI" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Получить текущий запущенный сценарий", "name": "Получить активный сценарий" diff --git a/custom_components/powercalc/translations/sk.json b/custom_components/powercalc/translations/sk.json index 94bb5d9..1c5db6a 100644 --- a/custom_components/powercalc/translations/sk.json +++ b/custom_components/powercalc/translations/sk.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Vytvorenie meracích prístrojov", + "daily_energy_value": "Daily energy value", "name": "Názov", "on_time": "V čase", "start_time": "Štart čas", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Výkon", - "power_template": "Šablóna výkonu", - "states_power": "Stavy výkonu" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Pevná hodnota výkonu vo wattoch, keď je entita ZAPNUTÁ", - "power_template": "Šablóna sa vyhodnotí, aby sa získala hodnota výkonu vo W", - "states_power": "Jeden pár „stav: výkon“ v každom riadku, pozri príklad vyššie" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Definujte pevnú hodnotu výkonu pre vašu entitu. Prípadne môžete definovať hodnotu výkonu pre každý stav. Napríklad:\n\n`prehrávanie: 8.3`\n`pozastavené: 2.25`", "title": "Opravená konfigurácia" @@ -290,6 +287,7 @@ "attribute": "Zadajte atribút. Keď zostane prázdne, bude jas pre svetlá a percento pre ventilátory", "calibrate": "Na každý riadok uveďte kalibračnú hodnotu. Príklad\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Lineárna konfigurácia" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Napájanie jedným vypínačom pri zapnutí", "power_off": "Napájanie na jeden vypínač pri vypnutí" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Konfigurácia viacerých prepínačov" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Prepínač, keď chcete pokračovať v opakovaní príručky po jej dokončení", "states_trigger": "Spustite playbook na základe zmeny stavu. Príklad\n\nprehrávanie: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Playbook konfigurácia" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Denné energetické možnosti", "data": { + "daily_energy_value": "Daily energy value", "name": "Názov", "on_time": "V čase", "start_time": "Čas začiatku", @@ -571,16 +572,11 @@ "fixed": { "title": "Pevné možnosti", "data": { - "power": "Výkon", - "power_template": "Výkon šablóna", - "self_usage_included": "Samostatné použitie v cene", - "states_power": "Výkonové stavy" + "fixed_value": "Fixed value", + "self_usage_included": "Samostatné použitie v cene" }, "data_description": { - "power": "Pevná hodnota výkonu vo wattoch, keď je entita ZAPNUTÁ", - "power_template": "Šablóna sa vyhodnotí, aby sa získala hodnota výkonu vo W", - "self_usage_included": "Či hodnota výkonu zahŕňa výkon spotrebovaný samotným inteligentným prepínačom.", - "states_power": "Jeden pár „stav: výkon“ v každom riadku, pozri príklad vyššie" + "self_usage_included": "Či hodnota výkonu zahŕňa výkon spotrebovaný samotným inteligentným prepínačom." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Spustenie vykonávania playbooku.", @@ -860,6 +871,10 @@ }, "name": "Zmeniť GUI nastavenie" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Získajte aktuálny playbook", "name": "Získať aktuálny playbook" diff --git a/custom_components/powercalc/translations/sv.json b/custom_components/powercalc/translations/sv.json index 8339611..6807d7a 100644 --- a/custom_components/powercalc/translations/sv.json +++ b/custom_components/powercalc/translations/sv.json @@ -48,6 +48,7 @@ "daily_energy": { "data": { "create_utility_meters": "Skapa bruksmätare", + "daily_energy_value": "Daily energy value", "name": "Namn", "on_time": "Igång tid", "start_time": "Starttid", @@ -87,14 +88,10 @@ }, "fixed": { "data": { - "power": "Effekt", - "power_template": "Effektmall", - "states_power": "Effekttillstånd" + "fixed_value": "Fixed value" }, "data_description": { - "power": "Ett fast värde i Watt när entiteten är igång", - "power_template": "Mall för att beräkna effekten i W", - "states_power": "En 'tillstånd:effekt' par per rad, se exempel ovan" + "fixed_value": "Fixed value in Watts when the entity is ON" }, "description": "Definiera ett fast värde för din entitet. Alternativt kan du definiera ett värde per tillstånd. Till exempel: \n\n`spelar: 8.3`\n`pausad: 2.25`", "title": "Fast konfig" @@ -290,6 +287,7 @@ "attribute": "Ange attributet. Om tomt används ljusstyrka för lampor och procent för fläktar", "calibrate": "Skriv ett kalibreringsvärde per rad. Exempel\n\n1: 20" }, + "description": "Define the linear power calculation options. See the [documentation]({docs_uri}) for more information. Use either min/max power or calibration values which allows for more points of control.", "title": "Linjär konfiguration" }, "manufacturer": { @@ -327,6 +325,7 @@ "power": "Effekt för enstaka enhet när påslagen", "power_off": "Effekt för enstaka enhet när avslagen" }, + "description": "Define the Multi switch power calculation options. See the [documentation]({docs_uri}) for more information.", "title": "Multi-enhetskonfiguration" }, "playbook": { @@ -341,6 +340,7 @@ "repeat": "Växla om du vill att playbooken ska repeteras när den är färdig", "states_trigger": "Utlösa en spelbok baserad på en tillståndsändring. Exempel\n\nspelar: program1" }, + "description": "Define the playbook options. See the [documentation]({docs_uri}) for more information.", "title": "Spelbok konfiguration" }, "power_advanced": { @@ -542,6 +542,7 @@ "daily_energy": { "title": "Daily energy options", "data": { + "daily_energy_value": "Daily energy value", "name": "Namn", "on_time": "Igång tid", "start_time": "Starttid", @@ -571,16 +572,11 @@ "fixed": { "title": "Fixed options", "data": { - "power": "effekt", - "power_template": "Effektmall", - "self_usage_included": "Self usage included", - "states_power": "Effekttillstånd" + "fixed_value": "Fixed value", + "self_usage_included": "Self usage included" }, "data_description": { - "power": "A fixed power value in Watts when the entity is ON", - "power_template": "Mall för att beräkna effekten i W", - "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself.", - "states_power": "En 'tillstånd:effekt' par per rad, se exempel ovan" + "self_usage_included": "Whether the power value includes the power consumed by the smart switch itself." } }, "global_configuration": { @@ -815,6 +811,21 @@ } } }, + "selector": { + "daily_energy_value": { + "choices": { + "value": "Value", + "value_template": "Value template" + } + }, + "fixed_value": { + "choices": { + "power": "Power", + "power_template": "Power template", + "states_power": "States power" + } + } + }, "services": { "activate_playbook": { "description": "Starts körningen sv en playbook.", @@ -860,6 +871,10 @@ }, "name": "Ändra GUI-konfiguration" }, + "debug_group": { + "description": "Get a debug overview of a group power or energy sensor including current member values.", + "name": "Debug group" + }, "get_active_playbook": { "description": "Get current running playbook", "name": "Get active playbook" diff --git a/zigbee2mqtt/coordinator_backup.json b/zigbee2mqtt/coordinator_backup.json index 6006cda..35a49a8 100644 --- a/zigbee2mqtt/coordinator_backup.json +++ b/zigbee2mqtt/coordinator_backup.json @@ -4,7 +4,7 @@ "version": 1, "source": "zigbee-herdsman@10.4.0", "internal": { - "date": "2026-06-14T02:45:15.859Z", + "date": "2026-06-15T13:46:01.495Z", "znpVersion": 1 } }, @@ -25,7 +25,7 @@ "network_key": { "key": "0a7ed3caf6adb653119697df9d04ec25", "sequence_number": 0, - "frame_counter": 408907 + "frame_counter": 480476 }, "devices": [ { @@ -35,7 +35,7 @@ "link_key": { "key": "a59c7f704c547dca7033b287b04ffc4b", "rx_counter": 0, - "tx_counter": 33 + "tx_counter": 55 } }, { @@ -45,7 +45,7 @@ "link_key": { "key": "91fa9ab38f0a04fc102f357e78f61f7d", "rx_counter": 0, - "tx_counter": 33 + "tx_counter": 55 } }, { @@ -55,7 +55,7 @@ "link_key": { "key": "a59a9f9bbb354104241b4a3476c2bd1f", "rx_counter": 0, - "tx_counter": 33 + "tx_counter": 55 } }, { @@ -65,7 +65,7 @@ "link_key": { "key": "d6d47144ee36ca67791986b8f5b74bb2", "rx_counter": 0, - "tx_counter": 33 + "tx_counter": 55 } }, { @@ -75,7 +75,7 @@ "link_key": { "key": "9b47a21f8d33962956b05e040cb24386", "rx_counter": 0, - "tx_counter": 33 + "tx_counter": 55 } }, { @@ -85,7 +85,7 @@ "link_key": { "key": "3537b9fd0d00b2cdf8c045e68c816762", "rx_counter": 0, - "tx_counter": 33 + "tx_counter": 55 } }, { @@ -95,7 +95,7 @@ "link_key": { "key": "85676341f405baf92aaa94bdef843b2c", "rx_counter": 0, - "tx_counter": 0 + "tx_counter": 22 } }, { @@ -105,7 +105,7 @@ "link_key": { "key": "fea770704c547dca2b08bd87b04ffc4b", "rx_counter": 0, - "tx_counter": 22 + "tx_counter": 44 } }, { @@ -115,7 +115,7 @@ "link_key": { "key": "facb3a7e78f61f7d7b1e95b38f0a04fc", "rx_counter": 0, - "tx_counter": 11 + "tx_counter": 33 } }, { @@ -125,7 +125,7 @@ "link_key": { "key": "452689b8f5b74bb2eaeb7e44ee36ca67", "rx_counter": 0, - "tx_counter": 0 + "tx_counter": 22 } } ]