diff --git a/custom_components/powercalc/config_flow.py b/custom_components/powercalc/config_flow.py index ad4133f25..c0f1bd1a7 100644 --- a/custom_components/powercalc/config_flow.py +++ b/custom_components/powercalc/config_flow.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_ATTRIBUTE, CONF_DEVICE, + CONF_ENTITIES, CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, @@ -54,12 +55,14 @@ CONF_MIN_POWER, CONF_MODE, CONF_MODEL, + CONF_MULTI_SWITCH, CONF_MULTIPLY_FACTOR, CONF_MULTIPLY_FACTOR_STANDBY, CONF_ON_TIME, CONF_PLAYBOOK, CONF_PLAYBOOKS, CONF_POWER, + CONF_POWER_OFF, CONF_POWER_TEMPLATE, CONF_REPEAT, CONF_SELF_USAGE_INCLUDED, @@ -88,6 +91,7 @@ ) from .discovery import get_power_profile_by_source_entity from .errors import ModelNotSupportedError, StrategyConfigurationError +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 DOMAIN_DEVICE_TYPE, DeviceType, PowerProfile @@ -117,6 +121,7 @@ class Steps(StrEnum): VIRTUAL_POWER = "virtual_power" FIXED = "fixed" LINEAR = "linear" + MULTI_SWITCH = "multi_switch" PLAYBOOK = "playbook" WLED = "wled" POWER_ADVANCED = "power_advanced" @@ -233,6 +238,7 @@ class Steps(StrEnum): options=[ CalculationStrategy.FIXED, CalculationStrategy.LINEAR, + CalculationStrategy.MULTI_SWITCH, CalculationStrategy.PLAYBOOK, CalculationStrategy.WLED, CalculationStrategy.LUT, @@ -265,6 +271,16 @@ class Steps(StrEnum): }, ) +SCHEMA_POWER_MULTI_SWITCH = vol.Schema( + { + vol.Required(CONF_ENTITIES): selector.EntitySelector( + selector.EntitySelectorConfig(domain=Platform.SWITCH, multiple=True), + ), + vol.Required(CONF_POWER): vol.Coerce(float), + vol.Required(CONF_POWER_OFF): vol.Coerce(float), + }, +) + SCHEMA_POWER_PLAYBOOK = vol.Schema( { vol.Optional(CONF_PLAYBOOKS): selector.ObjectSelector(), @@ -398,6 +414,8 @@ def create_strategy_schema(self, strategy: str, source_entity_id: str) -> vol.Sc return self.create_schema_linear(source_entity_id) if strategy == CalculationStrategy.PLAYBOOK: return SCHEMA_POWER_PLAYBOOK + if strategy == CalculationStrategy.MULTI_SWITCH: + return self.create_schema_multi_switch() if strategy == CalculationStrategy.WLED: return SCHEMA_POWER_WLED return vol.Schema({}) @@ -480,6 +498,16 @@ def create_schema_linear(source_entity_id: str) -> vol.Schema: }, ) + def create_schema_multi_switch(self) -> vol.Schema: + """Create the config schema for multi switch strategy.""" + schema = SCHEMA_POWER_MULTI_SWITCH + # Remove power options if we are in library flow as they are defined in the power profile + if self.is_library_flow: + del schema.schema[CONF_POWER] + del schema.schema[CONF_POWER_OFF] + schema = vol.Schema(schema.schema) + return schema + def create_schema_virtual_power( self, ) -> vol.Schema: @@ -705,7 +733,8 @@ async def async_step_integration_discovery( self.source_entity_id = self.source_entity.entity_id self.name = self.source_entity.name - unique_id = f"pc_{self.source_entity.unique_id}" + + unique_id = get_or_create_unique_id(self.sensor_config, self.source_entity, self.power_profile) await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @@ -765,18 +794,15 @@ async def async_step_virtual_power( self.source_entity_id, self.hass, ) - unique_id = user_input.get(CONF_UNIQUE_ID) - if not unique_id and self.source_entity_id != DUMMY_ENTITY_ID: - source_unique_id = self.source_entity.unique_id or self.source_entity_id - unique_id = f"pc_{source_unique_id}" - - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() self.name = user_input.get(CONF_NAME) or self.source_entity.name self.selected_sensor_type = SensorType.VIRTUAL_POWER self.sensor_config.update(user_input) + unique_id = get_or_create_unique_id(self.sensor_config, self.source_entity, self.power_profile) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return await self.forward_to_strategy_step(selected_strategy) return self.async_show_form( @@ -797,6 +823,9 @@ async def forward_to_strategy_step( if strategy == CalculationStrategy.LINEAR: return await self.async_step_linear() + if strategy == CalculationStrategy.MULTI_SWITCH: + return await self.async_step_multi_switch() + if strategy == CalculationStrategy.PLAYBOOK: return await self.async_step_playbook() @@ -927,6 +956,25 @@ async def async_step_linear( last_step=False, ) + async def async_step_multi_switch( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the flow for multi switch strategy.""" + errors = {} + if user_input is not None: + self.sensor_config.update({CONF_MULTI_SWITCH: user_input}) + errors = await self.validate_strategy_config() + if not errors: + return await self.async_step_power_advanced() + + return self.async_show_form( + step_id=Steps.MULTI_SWITCH, + data_schema=self.create_schema_multi_switch(), + errors=errors, + last_step=False, + ) + async def async_step_playbook( self, user_input: dict[str, Any] | None = None, @@ -1096,9 +1144,16 @@ async def async_step_post_library( if self.power_profile and self.power_profile.needs_fixed_config: return await self.async_step_fixed() - if self.power_profile and self.power_profile.device_type == DeviceType.SMART_SWITCH: + if ( + self.power_profile + and self.power_profile.device_type == DeviceType.SMART_SWITCH + and self.power_profile.calculation_strategy == CalculationStrategy.FIXED + ): return await self.async_step_smart_switch() + if self.power_profile and self.power_profile.calculation_strategy == CalculationStrategy.MULTI_SWITCH: + return await self.async_step_multi_switch() + return await self.async_step_power_advanced() async def async_step_sub_profile( diff --git a/custom_components/powercalc/const.py b/custom_components/powercalc/const.py index 0f2216d8c..e8ab09267 100644 --- a/custom_components/powercalc/const.py +++ b/custom_components/powercalc/const.py @@ -81,6 +81,7 @@ CONF_POWER_SENSOR_FRIENDLY_NAMING = "power_sensor_friendly_naming" CONF_POWER_SENSOR_PRECISION = "power_sensor_precision" CONF_POWER = "power" +CONF_POWER_OFF = "power_off" CONF_POWER_SENSOR_ID = "power_sensor_id" CONF_POWER_TEMPLATE = "power_template" CONF_PLAYBOOK = "playbook" diff --git a/custom_components/powercalc/discovery.py b/custom_components/powercalc/discovery.py index bfaba4053..3025d8601 100644 --- a/custom_components/powercalc/discovery.py +++ b/custom_components/powercalc/discovery.py @@ -28,6 +28,7 @@ CalculationStrategy, ) from .errors import ModelNotSupportedError +from .helpers import get_or_create_unique_id from .power_profile.factory import get_power_profile from .power_profile.library import ModelInfo from .power_profile.power_profile import DOMAIN_DEVICE_TYPE, PowerProfile @@ -57,9 +58,16 @@ def __init__(self, hass: HomeAssistant, ha_config: ConfigType) -> None: self.ha_config = ha_config self.power_profiles: dict[str, PowerProfile | None] = {} self.manually_configured_entities: list[str] | None = None + self.initialized_flows: set[str] = set() async def start_discovery(self) -> None: """Start the discovery procedure.""" + + existing_entries = self.hass.config_entries.async_entries(DOMAIN) + for entry in existing_entries: + if entry.unique_id: + self.initialized_flows.add(entry.unique_id) + _LOGGER.debug("Start auto discovering entities") entity_registry = er.async_get(self.hass) for entity_entry in list(entity_registry.entities.values()): @@ -221,12 +229,13 @@ def _init_entity_discovery( extra_discovery_data: dict | None, ) -> None: """Dispatch the discovery flow for a given entity.""" - existing_entries = [ - entry - for entry in self.hass.config_entries.async_entries(DOMAIN) - if entry.unique_id in [source_entity.unique_id, f"pc_{source_entity.unique_id}"] - ] - if existing_entries: + + unique_id = get_or_create_unique_id({}, source_entity, power_profile) + unique_ids_to_check = [unique_id] + if unique_id.startswith("pc_"): + unique_ids_to_check.append(unique_id[3:]) + + if any(unique_id in self.initialized_flows for unique_id in unique_ids_to_check): _LOGGER.debug( "%s: Already setup with discovery, skipping new discovery", source_entity.entity_id, @@ -246,6 +255,7 @@ def _init_entity_discovery( if extra_discovery_data: discovery_data.update(extra_discovery_data) + self.initialized_flows.add(unique_id) discovery_flow.async_create_flow( self.hass, DOMAIN, diff --git a/custom_components/powercalc/helpers.py b/custom_components/powercalc/helpers.py index 581e1860a..39b5012b7 100644 --- a/custom_components/powercalc/helpers.py +++ b/custom_components/powercalc/helpers.py @@ -1,9 +1,16 @@ import decimal import logging import os.path +import uuid from decimal import Decimal +from homeassistant.const import CONF_UNIQUE_ID from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType + +from custom_components.powercalc.common import SourceEntity +from custom_components.powercalc.const import DUMMY_ENTITY_ID, CalculationStrategy +from custom_components.powercalc.power_profile.power_profile import PowerProfile _LOGGER = logging.getLogger(__name__) @@ -35,3 +42,22 @@ def get_library_path(sub_path: str = "") -> str: def get_library_json_path() -> str: """Get the path to the library.json file.""" return get_library_path("library.json") + + +def get_or_create_unique_id(sensor_config: ConfigType, source_entity: SourceEntity, power_profile: PowerProfile | None) -> str: + """Get or create the unique id.""" + unique_id = sensor_config.get(CONF_UNIQUE_ID) + if unique_id: + return str(unique_id) + + # For multi-switch strategy we need to use the device id as unique id + # As we don't want to start a discovery for each switch entity + if power_profile and power_profile.calculation_strategy == CalculationStrategy.MULTI_SWITCH and source_entity.device_entry: + return f"pc_{source_entity.device_entry.id}" + + if source_entity and source_entity.entity_id != DUMMY_ENTITY_ID: + source_unique_id = source_entity.unique_id or source_entity.entity_id + # Prefix with pc_ to avoid conflicts with other integrations + return f"pc_{source_unique_id}" + + return str(uuid.uuid4()) diff --git a/custom_components/powercalc/strategy/factory.py b/custom_components/powercalc/strategy/factory.py index d76911b85..30516609e 100644 --- a/custom_components/powercalc/strategy/factory.py +++ b/custom_components/powercalc/strategy/factory.py @@ -1,5 +1,7 @@ from __future__ import annotations +from decimal import Decimal + from homeassistant.const import CONF_CONDITION, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers import condition @@ -14,6 +16,7 @@ CONF_MULTI_SWITCH, CONF_PLAYBOOK, CONF_POWER, + CONF_POWER_OFF, CONF_POWER_TEMPLATE, CONF_STANDBY_POWER, CONF_STATES_POWER, @@ -186,17 +189,26 @@ async def _create_sub_strategy(strategy_config: ConfigType) -> SubStrategy: def _create_multi_switch(self, config: ConfigType, power_profile: PowerProfile | None) -> MultiSwitchStrategy: """Create instance of multi switch strategy.""" - multi_switch_config = config.get(CONF_MULTI_SWITCH) - if multi_switch_config is None: - if power_profile and power_profile.get_strategy_config(CalculationStrategy.MULTI_SWITCH): - multi_switch_config = power_profile.get_strategy_config(CalculationStrategy.MULTI_SWITCH) + multi_switch_config: ConfigType = {} + if power_profile and power_profile.multi_switch_mode_config: + multi_switch_config = power_profile.multi_switch_mode_config + multi_switch_config.update(config.get(CONF_MULTI_SWITCH, {})) + + if not multi_switch_config: + raise StrategyConfigurationError("No multi_switch configuration supplied") + + entities: list[str] = multi_switch_config.get(CONF_ENTITIES, []) + if not entities: + raise StrategyConfigurationError("No switch entities supplied") - if multi_switch_config is None: - raise StrategyConfigurationError("No multi_switch configuration supplied") + on_power: Decimal | None = multi_switch_config.get(CONF_POWER) + off_power: Decimal | None = multi_switch_config.get(CONF_POWER_OFF) + if off_power is None or on_power is None: + raise StrategyConfigurationError("No power configuration supplied") return MultiSwitchStrategy( self._hass, - multi_switch_config.get(CONF_ENTITIES), # type: ignore - on_power=multi_switch_config.get(CONF_POWER), # type: ignore - off_power=config.get(CONF_STANDBY_POWER), # type: ignore + entities, + on_power=Decimal(on_power), + off_power=Decimal(off_power), ) diff --git a/custom_components/powercalc/strategy/multi_switch.py b/custom_components/powercalc/strategy/multi_switch.py index f1b94ea7b..84d34cb41 100644 --- a/custom_components/powercalc/strategy/multi_switch.py +++ b/custom_components/powercalc/strategy/multi_switch.py @@ -6,17 +6,18 @@ import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import CONF_ENTITIES, STATE_ON +from homeassistant.const import CONF_ENTITIES, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.helpers.event import TrackTemplate -from custom_components.powercalc.const import CONF_POWER +from custom_components.powercalc.const import CONF_POWER, CONF_POWER_OFF, DUMMY_ENTITY_ID from .strategy_interface import PowerCalculationStrategyInterface CONFIG_SCHEMA = vol.Schema( { - vol.Optional(CONF_POWER): vol.Any(vol.Coerce(float), cv.template), + vol.Optional(CONF_POWER): vol.Coerce(float), + vol.Optional(CONF_POWER_OFF): vol.Coerce(float), vol.Required(CONF_ENTITIES): cv.entities_domain(SWITCH_DOMAIN), }, ) @@ -40,11 +41,21 @@ def __init__( async def calculate(self, entity_state: State) -> Decimal | None: if self.known_states is None: - self.known_states = {entity_id: self.hass.states.get(entity_id) for entity_id in self.switch_entities} + 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 + } - self.known_states[entity_state.entity_id] = entity_state.state + if entity_state.entity_id != DUMMY_ENTITY_ID: + self.known_states[entity_state.entity_id] = entity_state.state - return Decimal(sum(self.on_power if state == STATE_ON else self.off_power for state in self.known_states.values())) + def _get_power(state: str) -> Decimal: + if state == STATE_UNAVAILABLE: + return Decimal(0) + if state == STATE_ON: + return self.on_power + return self.off_power + + 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 diff --git a/custom_components/powercalc/translations/en.json b/custom_components/powercalc/translations/en.json index 348a32ff6..1c6ea2816 100644 --- a/custom_components/powercalc/translations/en.json +++ b/custom_components/powercalc/translations/en.json @@ -121,6 +121,19 @@ "description": "Select the device model. See the [list]({supported_models_link}) of supported models for more information", "title": "Model config" }, + "multi_switch": { + "data": { + "entities": "Switch entities", + "power": "Power ON", + "power_off": "Power OFF" + }, + "data_description": { + "entities": "Select all the individual switches that are part of the multi switch", + "power": "Power for a single switch when turned on", + "power_off": "Power for a single switch when turned off" + }, + "title": "Multi switch config" + }, "playbook": { "data": { "autostart": "Autostart", @@ -276,6 +289,7 @@ "create_utility_meters": "Create utility meters", "device": "Device", "energy_integration_method": "Energy integration method", + "entities": "Switch entities", "gamma_curve": "Gamma curve", "group_energy_entities": "Additional energy entities", "group_member_sensors": "Member powercalc sensors", @@ -291,6 +305,7 @@ "on_time": "On time", "playbooks": "Playbooks", "power": "Power", + "power_off": "Power OFF", "power_template": "Power template", "repeat": "Repeat", "self_usage_included": "Self usage included", @@ -312,6 +327,7 @@ "calculation_enabled_condition": "The configured power calculation strategy will only be executed when this template evaluates to true or 1, otherwise the power sensor will display 0", "calibrate": "Put a calibration value on each line. Example\n\n1: 20", "device": "Add the group entities powercalc creates to an existing device", + "entities": "Select all the individual switches that are part of the multi switch", "group_energy_entities": "Additional energy sensors (kWh) from your HA installation to include", "group_member_sensors": "Powercalc sensors to include in the group", "group_power_entities": "Additional power sensors (W) from your HA installation to include", diff --git a/docs/source/strategies/multi_switch.rst b/docs/source/strategies/multi_switch.rst index 328e8d114..9e8746252 100644 --- a/docs/source/strategies/multi_switch.rst +++ b/docs/source/strategies/multi_switch.rst @@ -5,6 +5,9 @@ Multi Switch The multi switch strategy allows you to combine the self usage of multiple switches into a single entity. This can be used for example to make a profile for Tp-Link HS300 power strip. +You can setup sensors both with YAML or GUI. +When you use the GUI select :guilabel:`multi_switch` in the calculation_strategy dropdown. + Configuration options --------------------- @@ -13,7 +16,9 @@ Configuration options +===============+=========+==============+==========+==========================================================================================+ | entities | dict | **Required** | | Provide a list of the individual switch entities | +---------------+---------+--------------+----------+------------------------------------------------------------------------------------------+ -| power | decimal | **Optional** | | Power when switched on when one outlet is switched on | +| power | decimal | **Required** | | Power for one outlet when it is switched on | ++---------------+---------+--------------+----------+------------------------------------------------------------------------------------------+ +| power_off | decimal | **Required** | | Power for one outlet when it is switched off | +---------------+--------+---------------+----------+------------------------------------------------------------------------------------------+ .. code-block:: yaml @@ -21,12 +26,12 @@ Configuration options powercalc: sensors: - name: "My outlet self usage" - standby_power: 0.25 multi_switch: entities: - switch.outlet_1 - switch.outlet_2 - switch.outlet_3 + power_off: 0.25 power: 0.5 In this example, when all the switches are turned on, the power usage will be 0.5W * 3 = 1.5W diff --git a/profile_library/tp-link/HS300/model.json b/profile_library/tp-link/HS300/model.json new file mode 100644 index 000000000..5541b28d7 --- /dev/null +++ b/profile_library/tp-link/HS300/model.json @@ -0,0 +1,15 @@ +{ + "name": "Kasa Smart Wi-Fi Power Strip", + "device_type": "smart_switch", + "calculation_strategy": "multi_switch", + "multi_switch_config": { + "power": 0.725, + "power_off": 0.225 + }, + "sensor_config": { + "power_sensor_naming": "{} Device Power" + }, + "aliases": [ + "HS300(US)" + ] +} diff --git a/tests/config_flow/common.py b/tests/config_flow/common.py index 416ee72f5..fdab2feae 100644 --- a/tests/config_flow/common.py +++ b/tests/config_flow/common.py @@ -10,7 +10,10 @@ from homeassistant.helpers.typing import ConfigType from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.powercalc import DiscoveryManager +from custom_components.powercalc.common import SourceEntity from custom_components.powercalc.config_flow import ( + CONF_CONFIRM_AUTODISCOVERED_MODEL, DOMAIN, Steps, ) @@ -18,12 +21,18 @@ CONF_CREATE_ENERGY_SENSOR, CONF_CREATE_UTILITY_METERS, CONF_ENERGY_INTEGRATION_METHOD, + CONF_MANUFACTURER, CONF_MODE, + CONF_MODEL, CONF_SENSOR_TYPE, + DISCOVERY_POWER_PROFILE, + DISCOVERY_SOURCE_ENTITY, ENERGY_INTEGRATION_METHOD_LEFT, CalculationStrategy, SensorType, ) +from custom_components.powercalc.power_profile.factory import get_power_profile +from custom_components.powercalc.power_profile.power_profile import PowerProfile DEFAULT_ENTITY_ID = "light.test" DEFAULT_UNIQUE_ID = "7c009ef6829f" @@ -67,6 +76,43 @@ async def initialize_options_flow( return result +async def initialize_discovery_flow( + hass: HomeAssistant, + source_entity: SourceEntity, + power_profile: PowerProfile | None = None, + confirm_autodiscovered_model: bool = False, +) -> FlowResult: + discovery_manager: DiscoveryManager = DiscoveryManager(hass, {}) + if not power_profile: + power_profile = await get_power_profile( + hass, + {}, + await discovery_manager.autodiscover_model(source_entity.entity_entry), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + # CONF_UNIQUE_ID: DEFAULT_UNIQUE_ID, + CONF_NAME: "test", + CONF_ENTITY_ID: DEFAULT_ENTITY_ID, + CONF_MANUFACTURER: power_profile.manufacturer, + CONF_MODEL: power_profile.model, + DISCOVERY_SOURCE_ENTITY: source_entity, + DISCOVERY_POWER_PROFILE: power_profile, + }, + ) + if not confirm_autodiscovered_model: + return result + + assert result["type"] == data_entry_flow.FlowResultType.FORM + return await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_CONFIRM_AUTODISCOVERED_MODEL: True}, + ) + + async def goto_virtual_power_strategy_step( hass: HomeAssistant, strategy: CalculationStrategy, diff --git a/tests/config_flow/test_discovery.py b/tests/config_flow/test_discovery.py index 9681be230..871ff1e9c 100644 --- a/tests/config_flow/test_discovery.py +++ b/tests/config_flow/test_discovery.py @@ -1,9 +1,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult -from custom_components.powercalc import DOMAIN, DiscoveryManager from custom_components.powercalc.common import create_source_entity from custom_components.powercalc.config_flow import CONF_CONFIRM_AUTODISCOVERED_MODEL, Steps from custom_components.powercalc.const import ( @@ -12,8 +10,6 @@ CONF_MODEL, CONF_SENSOR_TYPE, CONF_SUB_PROFILE, - DISCOVERY_POWER_PROFILE, - DISCOVERY_SOURCE_ENTITY, SensorType, ) from custom_components.powercalc.discovery import get_power_profile_by_source_entity @@ -23,6 +19,7 @@ DEFAULT_ENTITY_ID, DEFAULT_UNIQUE_ID, create_mock_entry, + initialize_discovery_flow, initialize_options_flow, ) from tests.conftest import MockEntityWithModel @@ -40,26 +37,7 @@ async def test_discovery_flow( ) source_entity = await create_source_entity(DEFAULT_ENTITY_ID, hass) - discovery_manager: DiscoveryManager = DiscoveryManager(hass, {}) - power_profile = await get_power_profile( - hass, - {}, - await discovery_manager.autodiscover_model(source_entity.entity_entry), - ) - - result: FlowResult = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_UNIQUE_ID: DEFAULT_UNIQUE_ID, - CONF_NAME: "test", - CONF_ENTITY_ID: DEFAULT_ENTITY_ID, - CONF_MANUFACTURER: "signify", - CONF_MODEL: "LCT010", - DISCOVERY_SOURCE_ENTITY: source_entity, - DISCOVERY_POWER_PROFILE: power_profile, - }, - ) + result = await initialize_discovery_flow(hass, source_entity) # Confirm selected manufacturer/model assert result["type"] == data_entry_flow.FlowResultType.FORM @@ -83,20 +61,7 @@ async def test_discovery_flow_remarks_are_shown(hass: HomeAssistant) -> None: """Model.json can provide remarks to show in the discovery flow. Check if these are displayed correctly""" source_entity = await create_source_entity("media_player.test", hass) power_profile = await get_power_profile(hass, {}, ModelInfo("sonos", "one")) - - result: FlowResult = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_UNIQUE_ID: DEFAULT_UNIQUE_ID, - CONF_NAME: "test", - CONF_ENTITY_ID: "media_player.test", - CONF_MANUFACTURER: "sonos", - CONF_MODEL: "one", - DISCOVERY_SOURCE_ENTITY: source_entity, - DISCOVERY_POWER_PROFILE: power_profile, - }, - ) + result = await initialize_discovery_flow(hass, source_entity, power_profile) assert result["description_placeholders"]["remarks"] is not None @@ -114,19 +79,7 @@ async def test_discovery_flow_with_subprofile_selection( source_entity = await create_source_entity(DEFAULT_ENTITY_ID, hass) power_profile = await get_power_profile_by_source_entity(hass, source_entity) - result: FlowResult = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_ENTITY_ID: DEFAULT_ENTITY_ID, - CONF_NAME: "test", - CONF_MANUFACTURER: "lifx", - CONF_MODEL: "LIFX Z", - CONF_UNIQUE_ID: DEFAULT_UNIQUE_ID, - DISCOVERY_SOURCE_ENTITY: source_entity, - DISCOVERY_POWER_PROFILE: power_profile, - }, - ) + result = await initialize_discovery_flow(hass, source_entity, power_profile) assert result["type"] == data_entry_flow.FlowResultType.FORM result = await hass.config_entries.flow.async_configure( diff --git a/tests/config_flow/test_virtual_power_multi_switch.py b/tests/config_flow/test_virtual_power_multi_switch.py new file mode 100644 index 000000000..78cdde786 --- /dev/null +++ b/tests/config_flow/test_virtual_power_multi_switch.py @@ -0,0 +1,147 @@ +from unittest.mock import AsyncMock + +from homeassistant import data_entry_flow +from homeassistant.const import CONF_ENTITIES, CONF_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.entity_registry import RegistryEntry +from pytest_homeassistant_custom_component.common import mock_device_registry, mock_registry + +from custom_components.powercalc import DiscoveryManager +from custom_components.powercalc.common import create_source_entity +from custom_components.powercalc.config_flow import Steps +from custom_components.powercalc.const import ( + CONF_MANUFACTURER, + CONF_MODE, + CONF_MODEL, + CONF_MULTI_SWITCH, + CONF_POWER, + CONF_POWER_OFF, + CONF_SENSOR_TYPE, + CalculationStrategy, + SensorType, +) +from tests.common import get_test_config_dir +from tests.config_flow.common import ( + DEFAULT_UNIQUE_ID, + goto_virtual_power_strategy_step, + initialize_discovery_flow, + set_virtual_power_configuration, +) +from tests.conftest import MockEntityWithModel + + +async def test_create_multi_switch_sensor_entry(hass: HomeAssistant) -> None: + result = await goto_virtual_power_strategy_step(hass, CalculationStrategy.MULTI_SWITCH, {CONF_NAME: "test"}) + result = await set_virtual_power_configuration( + hass, + result, + {CONF_ENTITIES: ["switch.a", "switch.b"], CONF_POWER: 0.8, CONF_POWER_OFF: 0.5}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + entry_data = result["data"] + assert entry_data[CONF_SENSOR_TYPE] == SensorType.VIRTUAL_POWER + assert entry_data[CONF_MODE] == CalculationStrategy.MULTI_SWITCH + assert entry_data[CONF_MULTI_SWITCH] == {CONF_ENTITIES: ["switch.a", "switch.b"], CONF_POWER: 0.8, CONF_POWER_OFF: 0.5} + + await hass.async_block_till_done() + assert hass.states.get("sensor.test_power") + assert hass.states.get("sensor.test_energy") + + entity_reg = er.async_get(hass) + entry = entity_reg.async_get("sensor.test_power") + assert entry + + +async def test_discovery_flow( + hass: HomeAssistant, + mock_entity_with_model_information: MockEntityWithModel, +) -> None: + hass.config.config_dir = get_test_config_dir() + manufacturer = "tp-link" + model = "HS300" + mock_entity_with_model_information( + "switch.test", + manufacturer, + model, + unique_id=DEFAULT_UNIQUE_ID, + ) + + source_entity = await create_source_entity("switch.test", hass) + result = await initialize_discovery_flow(hass, source_entity, confirm_autodiscovered_model=True) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == Steps.MULTI_SWITCH + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ENTITIES: ["switch.a", "switch.b"]}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ENTITY_ID: "switch.test", + CONF_SENSOR_TYPE: SensorType.VIRTUAL_POWER, + CONF_MANUFACTURER: manufacturer, + CONF_MODEL: model, + CONF_NAME: "test", + CONF_UNIQUE_ID: f"pc_{DEFAULT_UNIQUE_ID}", + CONF_MULTI_SWITCH: { + CONF_ENTITIES: ["switch.a", "switch.b"], + }, + } + + assert hass.states.get("sensor.test_device_power") + + hass.states.async_set("switch.a", STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test_device_power").state == "0.82" + + hass.states.async_set("switch.b", STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test_device_power").state == "1.64" + + +async def test_discovery_flow_once_per_unique_device( + hass: HomeAssistant, + mock_flow_init: AsyncMock, +) -> None: + hass.config.config_dir = get_test_config_dir() + + device_id = "abcdef" + mock_device_registry( + hass, + { + device_id: DeviceEntry( + id=device_id, + manufacturer="tp-link", + model="HS300", + ), + }, + ) + + entities: dict[str, RegistryEntry] = {} + for i in range(6): + entity_id = f"switch.test{i}" + entry = RegistryEntry( + id=entity_id, + entity_id=entity_id, + unique_id=f"{device_id}{i}", + device_id=device_id, + platform="switch", + ) + entities[entity_id] = entry + + mock_registry( + hass, + entities, + ) + + discovery_manager = DiscoveryManager(hass, {}) + await discovery_manager.start_discovery() + + assert len(mock_flow_init.mock_calls) == 1 diff --git a/tests/conftest.py b/tests/conftest.py index a3ef2f778..7fec4d1b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,6 +118,9 @@ def _mock_entity_with_model_information( **entity_reg_kwargs: Any, # noqa: ANN401 ) -> None: device_id = str(uuid.uuid4()) + if "device_id" in entity_reg_kwargs: + device_id = entity_reg_kwargs["device_id"] + del entity_reg_kwargs["device_id"] unique_id = str(uuid.uuid4()) if "unique_id" in entity_reg_kwargs: diff --git a/tests/power_profile/device_types/test_multi_switch.py b/tests/power_profile/device_types/test_multi_switch.py index a287aea0b..0ca95fe9e 100644 --- a/tests/power_profile/device_types/test_multi_switch.py +++ b/tests/power_profile/device_types/test_multi_switch.py @@ -6,8 +6,6 @@ CONF_MANUFACTURER, CONF_MODEL, CONF_MULTI_SWITCH, - CONF_POWER, - CONF_STANDBY_POWER, ) from tests.common import get_test_profile_dir, run_powercalc_setup from tests.conftest import MockEntityWithModel @@ -33,7 +31,6 @@ async def test_multi_switch( power_sensor_id = "sensor.outlet1_device_power" switch1_id = "switch.outlet1" switch2_id = "switch.outlet2" - switch3_id = "switch.outlet3" await run_powercalc_setup( hass, @@ -42,14 +39,8 @@ async def test_multi_switch( CONF_MANUFACTURER: manufacturer, CONF_MODEL: model, CONF_CUSTOM_MODEL_DIRECTORY: get_test_profile_dir("multi_switch"), - CONF_STANDBY_POWER: 0.25, CONF_MULTI_SWITCH: { - CONF_POWER: 0.687, - CONF_ENTITIES: [ - switch1_id, - switch2_id, - switch3_id, - ], + CONF_ENTITIES: [switch1_id, switch2_id], }, }, ) @@ -61,14 +52,14 @@ async def test_multi_switch( hass.states.async_set(switch1_id, STATE_ON) await hass.async_block_till_done() - assert hass.states.get(power_sensor_id).state == "1.19" + assert hass.states.get(power_sensor_id).state == "0.69" hass.states.async_set(switch2_id, STATE_ON) await hass.async_block_till_done() - assert hass.states.get(power_sensor_id).state == "1.62" + assert hass.states.get(power_sensor_id).state == "1.38" hass.states.async_set(switch2_id, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get(power_sensor_id).state == "1.19" + assert hass.states.get(power_sensor_id).state == "0.94" diff --git a/tests/strategy/test_multi_switch.py b/tests/strategy/test_multi_switch.py index 725a80a03..93a835713 100644 --- a/tests/strategy/test_multi_switch.py +++ b/tests/strategy/test_multi_switch.py @@ -1,5 +1,6 @@ from decimal import Decimal +import pytest from homeassistant.const import ( CONF_ENTITIES, CONF_NAME, @@ -7,8 +8,12 @@ STATE_ON, ) from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.typing import ConfigType -from custom_components.powercalc.const import CONF_MULTI_SWITCH, CONF_POWER, CONF_STANDBY_POWER +from custom_components.powercalc import PowerCalculatorStrategyFactory +from custom_components.powercalc.common import create_source_entity +from custom_components.powercalc.const import CONF_MULTI_SWITCH, CONF_POWER, CONF_POWER_OFF, CalculationStrategy +from custom_components.powercalc.errors import StrategyConfigurationError from custom_components.powercalc.strategy.multi_switch import MultiSwitchStrategy from tests.common import run_powercalc_setup @@ -25,10 +30,10 @@ async def test_calculate_sum(hass: HomeAssistant) -> None: off_power=Decimal(0.25), ) - assert await strategy.calculate(State(switch1, STATE_OFF)) == 0.75 - assert await strategy.calculate(State(switch1, STATE_ON)) == 1.00 - assert await strategy.calculate(State(switch2, STATE_ON)) == 1.25 - assert await strategy.calculate(State(switch3, STATE_ON)) == 1.50 + assert await strategy.calculate(State(switch1, STATE_OFF)) == Decimal(0.25) + assert await strategy.calculate(State(switch1, STATE_ON)) == Decimal(0.50) + assert await strategy.calculate(State(switch2, STATE_ON)) == Decimal(1.00) + assert await strategy.calculate(State(switch3, STATE_ON)) == Decimal(1.50) async def test_setup_using_yaml(hass: HomeAssistant) -> None: @@ -36,9 +41,9 @@ async def test_setup_using_yaml(hass: HomeAssistant) -> None: hass, { CONF_NAME: "Outlet self usage", - CONF_STANDBY_POWER: 0.25, CONF_MULTI_SWITCH: { CONF_POWER: 0.5, + CONF_POWER_OFF: 0.25, CONF_ENTITIES: [ "switch.test1", "switch.test2", @@ -54,3 +59,51 @@ async def test_setup_using_yaml(hass: HomeAssistant) -> None: power_sensor = hass.states.get("sensor.outlet_self_usage_power") assert power_sensor + + +@pytest.mark.parametrize( + "config", + [ + { + CONF_NAME: "My sensor", + }, + { + CONF_NAME: "My sensor", + CONF_MULTI_SWITCH: { + CONF_POWER: 0.5, + CONF_POWER_OFF: 1, + }, + }, + { + CONF_NAME: "My sensor", + CONF_MULTI_SWITCH: { + CONF_POWER: 0.5, + CONF_ENTITIES: [ + "switch.test1", + "switch.test2", + "switch.test3", + ], + }, + }, + { + CONF_NAME: "My sensor", + CONF_MULTI_SWITCH: { + CONF_POWER_OFF: 0.5, + CONF_ENTITIES: [ + "switch.test1", + "switch.test2", + "switch.test3", + ], + }, + }, + ], +) +async def test_strategy_configuration_error(hass: HomeAssistant, config: ConfigType) -> None: + with pytest.raises(StrategyConfigurationError): + factory = PowerCalculatorStrategyFactory(hass) + await factory.create( + config, + CalculationStrategy.MULTI_SWITCH, + None, + await create_source_entity("switch.test1", hass), + ) diff --git a/tests/testing_config/powercalc/profiles/tp-link/HS300/model.json b/tests/testing_config/powercalc/profiles/tp-link/HS300/model.json new file mode 100644 index 000000000..e0b2a2b9d --- /dev/null +++ b/tests/testing_config/powercalc/profiles/tp-link/HS300/model.json @@ -0,0 +1,12 @@ +{ + "name": "IKEA Control outlet", + "device_type": "smart_switch", + "calculation_strategy": "multi_switch", + "multi_switch_config": { + "power": 0.82, + "power_off": 0.52 + }, + "sensor_config": { + "power_sensor_naming": "{} Device Power" + } +} diff --git a/tests/testing_config/powercalc_profiles/multi_switch/model.json b/tests/testing_config/powercalc_profiles/multi_switch/model.json index 3cca5fdef..aa8d517b6 100644 --- a/tests/testing_config/powercalc_profiles/multi_switch/model.json +++ b/tests/testing_config/powercalc_profiles/multi_switch/model.json @@ -1,9 +1,11 @@ { "name": "Kasa Smart Wi-Fi Power Strip", - "standby_power": 0.25, - "standby_power_on": 0.687, "device_type": "smart_switch", "calculation_strategy": "multi_switch", + "multi_switch_config": { + "power": 0.6875, + "power_off": 0.25 + }, "sensor_config": { "power_sensor_naming": "{} Device Power" }