diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index 9bd772264dd..16d8158f7e2 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -59,13 +59,38 @@ class FlowToGridSourceType(TypedDict): number_energy_price: float | None # Price for energy ($/kWh) -class GridPowerSourceType(TypedDict): +class PowerConfig(TypedDict, total=False): + """Dictionary holding power sensor configuration options. + + Users can configure power sensors in three ways: + 1. Standard: single sensor (positive=discharge/from_grid, negative=charge/to_grid) + 2. Inverted: single sensor with opposite polarity (needs to be multiplied by -1) + 3. Two sensors: separate positive sensors for each direction + """ + + # Standard: single sensor (positive=discharge/from_grid, negative=charge/to_grid) + stat_rate: str + + # Inverted: single sensor with opposite polarity (needs to be multiplied by -1) + stat_rate_inverted: str + + # Two sensors: separate positive sensors for each direction + # Result = stat_rate_from - stat_rate_to (positive when net outflow) + stat_rate_from: str # Battery: discharge, Grid: consumption + stat_rate_to: str # Battery: charge, Grid: return + + +class GridPowerSourceType(TypedDict, total=False): """Dictionary holding the source of grid power consumption.""" # statistic_id of a power meter (kW) # negative values indicate grid return + # This is either the original sensor or a generated template sensor stat_rate: str + # User's original power sensor configuration + power_config: PowerConfig + class GridSourceType(TypedDict): """Dictionary holding the source of grid energy consumption.""" @@ -97,8 +122,12 @@ class BatterySourceType(TypedDict): stat_energy_from: str stat_energy_to: str # positive when discharging, negative when charging + # This is either the original sensor or a generated template sensor stat_rate: NotRequired[str] + # User's original power sensor configuration + power_config: NotRequired[PowerConfig] + class GasSourceType(TypedDict): """Dictionary holding the source of gas consumption.""" @@ -211,10 +240,53 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema( } ) -GRID_POWER_SOURCE_SCHEMA = vol.Schema( - { - vol.Required("stat_rate"): str, - } + +def _validate_power_config(val: dict[str, Any]) -> dict[str, Any]: + """Validate power_config has exactly one configuration method.""" + if not val: + raise vol.Invalid("power_config must have at least one option") + + # Ensure only one configuration method is used + has_single = "stat_rate" in val + has_inverted = "stat_rate_inverted" in val + has_combined = "stat_rate_from" in val + + methods_count = sum([has_single, has_inverted, has_combined]) + if methods_count > 1: + raise vol.Invalid( + "power_config must use only one configuration method: " + "stat_rate, stat_rate_inverted, or stat_rate_from/stat_rate_to" + ) + + return val + + +POWER_CONFIG_SCHEMA = vol.All( + vol.Schema( + { + vol.Exclusive("stat_rate", "power_source"): str, + vol.Exclusive("stat_rate_inverted", "power_source"): str, + # stat_rate_from/stat_rate_to: two sensors for bidirectional power + # Battery: from=discharge (out), to=charge (in) + # Grid: from=consumption, to=return + vol.Inclusive("stat_rate_from", "two_sensors"): str, + vol.Inclusive("stat_rate_to", "two_sensors"): str, + } + ), + _validate_power_config, +) + + +GRID_POWER_SOURCE_SCHEMA = vol.All( + vol.Schema( + { + # stat_rate and power_config are both optional schema keys, but the validator + # requires that at least one is provided; power_config takes precedence + vol.Optional("stat_rate"): str, + vol.Optional("power_config"): POWER_CONFIG_SCHEMA, + } + ), + cv.has_at_least_one_key("stat_rate", "power_config"), ) @@ -225,7 +297,7 @@ def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[di val: list[dict], ) -> list[dict]: """Ensure that the user doesn't add duplicate values.""" - counts = Counter(flow_from[key] for flow_from in val) + counts = Counter(item.get(key) for item in val if item.get(key) is not None) for value, count in counts.items(): if count > 1: @@ -267,7 +339,10 @@ BATTERY_SOURCE_SCHEMA = vol.Schema( vol.Required("type"): "battery", vol.Required("stat_energy_from"): str, vol.Required("stat_energy_to"): str, + # Both stat_rate and power_config are optional + # If power_config is provided, it takes precedence and stat_rate is overwritten vol.Optional("stat_rate"): str, + vol.Optional("power_config"): POWER_CONFIG_SCHEMA, } ) GAS_SOURCE_SCHEMA = vol.Schema( @@ -387,6 +462,12 @@ class EnergyManager: if key in update: data[key] = update[key] + # Process energy sources and set stat_rate for power configs + if "energy_sources" in update: + data["energy_sources"] = self._process_energy_sources( + data["energy_sources"] + ) + self.data = data self._store.async_delay_save(lambda: data, 60) @@ -395,6 +476,68 @@ class EnergyManager: await asyncio.gather(*(listener() for listener in self._update_listeners)) + def _process_energy_sources(self, sources: list[SourceType]) -> list[SourceType]: + """Process energy sources and set stat_rate for power configs.""" + from .helpers import generate_power_sensor_entity_id # noqa: PLC0415 + + processed: list[SourceType] = [] + for source in sources: + if source["type"] == "battery": + source = self._process_battery_power( + source, generate_power_sensor_entity_id + ) + elif source["type"] == "grid": + source = self._process_grid_power( + source, generate_power_sensor_entity_id + ) + processed.append(source) + return processed + + def _process_battery_power( + self, + source: BatterySourceType, + generate_entity_id: Callable[[str, PowerConfig], str], + ) -> BatterySourceType: + """Set stat_rate for battery if power_config is specified.""" + if "power_config" not in source: + return source + + config = source["power_config"] + + # If power_config has stat_rate (standard), just use it directly + if "stat_rate" in config: + return {**source, "stat_rate": config["stat_rate"]} + + # For inverted or two-sensor config, set stat_rate to the generated entity_id + return {**source, "stat_rate": generate_entity_id("battery", config)} + + def _process_grid_power( + self, + source: GridSourceType, + generate_entity_id: Callable[[str, PowerConfig], str], + ) -> GridSourceType: + """Set stat_rate for grid power sources if power_config is specified.""" + if "power" not in source: + return source + + processed_power: list[GridPowerSourceType] = [] + for power in source["power"]: + if "power_config" in power: + config = power["power_config"] + + # If power_config has stat_rate (standard), just use it directly + if "stat_rate" in config: + processed_power.append({**power, "stat_rate": config["stat_rate"]}) + else: + # For inverted or two-sensor config, set stat_rate to generated entity_id + processed_power.append( + {**power, "stat_rate": generate_entity_id("grid", config)} + ) + else: + processed_power.append(power) + + return {**source, "power": processed_power} + @callback def async_listen_updates(self, update_listener: Callable[[], Awaitable]) -> None: """Listen for data updates.""" diff --git a/homeassistant/components/energy/helpers.py b/homeassistant/components/energy/helpers.py new file mode 100644 index 00000000000..f97e598cc04 --- /dev/null +++ b/homeassistant/components/energy/helpers.py @@ -0,0 +1,42 @@ +"""Helpers for the Energy integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .data import PowerConfig + + +def generate_power_sensor_unique_id(source_type: str, config: PowerConfig) -> str: + """Generate a unique ID for a power transform sensor.""" + if "stat_rate_inverted" in config: + sensor_id = config["stat_rate_inverted"].replace(".", "_") + return f"energy_power_{source_type}_inverted_{sensor_id}" + if "stat_rate_from" in config and "stat_rate_to" in config: + from_id = config["stat_rate_from"].replace(".", "_") + to_id = config["stat_rate_to"].replace(".", "_") + return f"energy_power_{source_type}_combined_{from_id}_{to_id}" + # This case is impossible: schema validation (vol.Inclusive) ensures + # stat_rate_from and stat_rate_to are always present together + raise RuntimeError("Invalid power config: missing required keys") + + +def generate_power_sensor_entity_id(source_type: str, config: PowerConfig) -> str: + """Generate an entity ID for a power transform sensor.""" + if "stat_rate_inverted" in config: + # Use source sensor name with _inverted suffix + source = config["stat_rate_inverted"] + if source.startswith("sensor."): + return f"{source}_inverted" + return f"sensor.{source.replace('.', '_')}_inverted" + if "stat_rate_from" in config and "stat_rate_to" in config: + # Use both sensors in entity ID to ensure uniqueness when multiple + # combined configs exist. The entity represents net power (from - to), + # e.g., discharge - charge for battery. + from_sensor = config["stat_rate_from"].removeprefix("sensor.") + to_sensor = config["stat_rate_to"].removeprefix("sensor.") + return f"sensor.energy_{source_type}_{from_sensor}_{to_sensor}_net_power" + # This case is impossible: schema validation (vol.Inclusive) ensures + # stat_rate_from and stat_rate_to are always present together + raise RuntimeError("Invalid power config: missing required keys") diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index ccbc1dd5c7a..3a512dc5211 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -19,7 +19,12 @@ from homeassistant.components.sensor import ( from homeassistant.components.sensor.recorder import ( # pylint: disable=hass-component-root-import reset_detected, ) -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) from homeassistant.core import ( HomeAssistant, State, @@ -36,7 +41,8 @@ from homeassistant.util import dt as dt_util, unit_conversion from homeassistant.util.unit_system import METRIC_SYSTEM from .const import DOMAIN -from .data import EnergyManager, async_get_manager +from .data import EnergyManager, PowerConfig, async_get_manager +from .helpers import generate_power_sensor_entity_id, generate_power_sensor_unique_id SUPPORTED_STATE_CLASSES = { SensorStateClass.MEASUREMENT, @@ -137,6 +143,7 @@ class SensorManager: self.manager = manager self.async_add_entities = async_add_entities self.current_entities: dict[tuple[str, str | None, str], EnergyCostSensor] = {} + self.current_power_entities: dict[str, EnergyPowerSensor] = {} async def async_start(self) -> None: """Start.""" @@ -147,8 +154,9 @@ class SensorManager: async def _process_manager_data(self) -> None: """Process manager data.""" - to_add: list[EnergyCostSensor] = [] + to_add: list[EnergyCostSensor | EnergyPowerSensor] = [] to_remove = dict(self.current_entities) + power_to_remove = dict(self.current_power_entities) async def finish() -> None: if to_add: @@ -159,6 +167,13 @@ class SensorManager: self.current_entities.pop(key) await entity.async_remove() + for power_key, power_entity in power_to_remove.items(): + self.current_power_entities.pop(power_key) + await power_entity.async_remove() + + # This guard is for the optional typing of EnergyManager.data. + # In practice, data is always set to default preferences in async_update + # before listeners are called, so this case should never happen. if not self.manager.data: await finish() return @@ -185,6 +200,13 @@ class SensorManager: to_remove, ) + # Process power sensors for battery and grid sources + self._process_power_sensor_data( + energy_source, + to_add, + power_to_remove, + ) + await finish() @callback @@ -192,7 +214,7 @@ class SensorManager: self, adapter: SourceAdapter, config: Mapping[str, Any], - to_add: list[EnergyCostSensor], + to_add: list[EnergyCostSensor | EnergyPowerSensor], to_remove: dict[tuple[str, str | None, str], EnergyCostSensor], ) -> None: """Process sensor data.""" @@ -220,6 +242,64 @@ class SensorManager: ) to_add.append(self.current_entities[key]) + @callback + def _process_power_sensor_data( + self, + energy_source: Mapping[str, Any], + to_add: list[EnergyCostSensor | EnergyPowerSensor], + to_remove: dict[str, EnergyPowerSensor], + ) -> None: + """Process power sensor data for battery and grid sources.""" + source_type = energy_source.get("type") + + if source_type == "battery": + power_config = energy_source.get("power_config") + if power_config and self._needs_power_sensor(power_config): + self._create_or_keep_power_sensor( + source_type, power_config, to_add, to_remove + ) + + elif source_type == "grid": + for power in energy_source.get("power", []): + power_config = power.get("power_config") + if power_config and self._needs_power_sensor(power_config): + self._create_or_keep_power_sensor( + source_type, power_config, to_add, to_remove + ) + + @staticmethod + def _needs_power_sensor(power_config: PowerConfig) -> bool: + """Check if power_config needs a transform sensor.""" + # Only create sensors for inverted or two-sensor configs + # Standard stat_rate configs don't need a transform sensor + return "stat_rate_inverted" in power_config or ( + "stat_rate_from" in power_config and "stat_rate_to" in power_config + ) + + def _create_or_keep_power_sensor( + self, + source_type: str, + power_config: PowerConfig, + to_add: list[EnergyCostSensor | EnergyPowerSensor], + to_remove: dict[str, EnergyPowerSensor], + ) -> None: + """Create a power sensor or keep an existing one.""" + unique_id = generate_power_sensor_unique_id(source_type, power_config) + + # If entity already exists, keep it + if unique_id in to_remove: + to_remove.pop(unique_id) + return + + sensor = EnergyPowerSensor( + source_type, + power_config, + unique_id, + generate_power_sensor_entity_id(source_type, power_config), + ) + self.current_power_entities[unique_id] = sensor + to_add.append(sensor) + def _set_result_unless_done(future: asyncio.Future[None]) -> None: """Set the result of a future unless it is done.""" @@ -495,3 +575,197 @@ class EnergyCostSensor(SensorEntity): prefix = self._config[self._adapter.stat_energy_key] return f"{prefix}_{self._adapter.source_type}_{self._adapter.entity_id_suffix}" + + +class EnergyPowerSensor(SensorEntity): + """Transform power sensor values (invert or combine two sensors). + + This sensor handles non-standard power sensor configurations for the energy + dashboard by either inverting polarity or combining two positive sensors. + """ + + _attr_should_poll = False + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True + + def __init__( + self, + source_type: str, + config: PowerConfig, + unique_id: str, + entity_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__() + self._source_type = source_type + self._config: PowerConfig = config + self._attr_unique_id = unique_id + self.entity_id = entity_id + self._source_sensors: list[str] = [] + self._is_inverted = "stat_rate_inverted" in config + self._is_combined = "stat_rate_from" in config and "stat_rate_to" in config + + # Determine source sensors + if self._is_inverted: + self._source_sensors = [config["stat_rate_inverted"]] + elif self._is_combined: + self._source_sensors = [ + config["stat_rate_from"], + config["stat_rate_to"], + ] + + # add_finished is set when either async_added_to_hass or add_to_platform_abort + # is called + self.add_finished: asyncio.Future[None] = ( + asyncio.get_running_loop().create_future() + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + if self._is_inverted: + source = self.hass.states.get(self._source_sensors[0]) + return source is not None and source.state not in ( + "unknown", + "unavailable", + ) + if self._is_combined: + discharge = self.hass.states.get(self._source_sensors[0]) + charge = self.hass.states.get(self._source_sensors[1]) + return ( + discharge is not None + and charge is not None + and discharge.state not in ("unknown", "unavailable") + and charge.state not in ("unknown", "unavailable") + ) + return True + + @callback + def _update_state(self) -> None: + """Update the sensor state based on source sensors.""" + if self._is_inverted: + source_state = self.hass.states.get(self._source_sensors[0]) + if source_state is None or source_state.state in ("unknown", "unavailable"): + self._attr_native_value = None + return + try: + value = float(source_state.state) + except ValueError: + self._attr_native_value = None + return + + self._attr_native_value = value * -1 + + elif self._is_combined: + discharge_state = self.hass.states.get(self._source_sensors[0]) + charge_state = self.hass.states.get(self._source_sensors[1]) + + if ( + discharge_state is None + or charge_state is None + or discharge_state.state in ("unknown", "unavailable") + or charge_state.state in ("unknown", "unavailable") + ): + self._attr_native_value = None + return + + try: + discharge = float(discharge_state.state) + charge = float(charge_state.state) + except ValueError: + self._attr_native_value = None + return + + # Get units from state attributes + discharge_unit = discharge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + charge_unit = charge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + # Convert to Watts if units are present + if discharge_unit: + discharge = unit_conversion.PowerConverter.convert( + discharge, discharge_unit, UnitOfPower.WATT + ) + if charge_unit: + charge = unit_conversion.PowerConverter.convert( + charge, charge_unit, UnitOfPower.WATT + ) + + self._attr_native_value = discharge - charge + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + # Set name based on source sensor(s) + if self._source_sensors: + entity_reg = er.async_get(self.hass) + device_id = None + source_name = None + # Check first sensor + if source_entry := entity_reg.async_get(self._source_sensors[0]): + device_id = source_entry.device_id + # For combined mode, always use Watts because we may have different source units; for inverted mode, copy source unit + if self._is_combined: + self._attr_native_unit_of_measurement = UnitOfPower.WATT + else: + self._attr_native_unit_of_measurement = ( + source_entry.unit_of_measurement + ) + # Get source name from registry + source_name = source_entry.name or source_entry.original_name + # Assign power sensor to same device as source sensor(s) + # Note: We use manual entity registry update instead of _attr_device_info + # because device assignment depends on runtime information from the entity + # registry (which source sensor has a device). This information isn't + # available during __init__, and the entity is already registered before + # async_added_to_hass runs, making the standard _attr_device_info pattern + # incompatible with this use case. + # If first sensor has no device and we have a second sensor, check it + if not device_id and len(self._source_sensors) > 1: + if source_entry := entity_reg.async_get(self._source_sensors[1]): + device_id = source_entry.device_id + # Update entity registry entry with device_id + if device_id and (power_entry := entity_reg.async_get(self.entity_id)): + entity_reg.async_update_entity( + power_entry.entity_id, device_id=device_id + ) + else: + self._attr_has_entity_name = False + + # Set name for inverted mode + if self._is_inverted: + if source_name: + self._attr_name = f"{source_name} Inverted" + else: + # Fall back to entity_id if no name in registry + sensor_name = split_entity_id(self._source_sensors[0])[1].replace( + "_", " " + ) + self._attr_name = f"{sensor_name.title()} Inverted" + + # Set name for combined mode + if self._is_combined: + self._attr_name = f"{self._source_type.title()} Power" + + self._update_state() + + # Track state changes on all source sensors + self.async_on_remove( + async_track_state_change_event( + self.hass, + self._source_sensors, + self._async_state_changed_listener, + ) + ) + _set_result_unless_done(self.add_finished) + + @callback + def _async_state_changed_listener(self, *_: Any) -> None: + """Handle source sensor state changes.""" + self._update_state() + self.async_write_ha_state() + + @callback + def add_to_platform_abort(self) -> None: + """Abort adding an entity to a platform.""" + _set_result_unless_done(self.add_finished) + super().add_to_platform_abort() diff --git a/tests/components/energy/test_data.py b/tests/components/energy/test_data.py index 3f6bd8fa2e5..69f0919dacd 100644 --- a/tests/components/energy/test_data.py +++ b/tests/components/energy/test_data.py @@ -1,6 +1,14 @@ """Test energy data storage and migration.""" -from homeassistant.components.energy.data import EnergyManager +import pytest +import voluptuous as vol + +from homeassistant.components.energy.data import ( + ENERGY_SOURCE_SCHEMA, + FLOW_FROM_GRID_SOURCE_SCHEMA, + POWER_CONFIG_SCHEMA, + EnergyManager, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import storage @@ -72,3 +80,406 @@ async def test_energy_preferences_migration_from_old_version( assert manager.data is not None assert "device_consumption_water" in manager.data assert manager.data["device_consumption_water"] == [] + + +async def test_battery_power_config_inverted_sets_stat_rate( + hass: HomeAssistant, +) -> None: + """Test that battery with inverted power_config sets stat_rate to generated entity_id.""" + manager = EnergyManager(hass) + await manager.async_initialize() + manager.data = manager.default_preferences() + + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + + # Verify stat_rate was set to the expected entity_id + assert manager.data is not None + assert len(manager.data["energy_sources"]) == 1 + source = manager.data["energy_sources"][0] + assert source["stat_rate"] == "sensor.battery_power_inverted" + # Verify power_config is preserved + assert source["power_config"] == {"stat_rate_inverted": "sensor.battery_power"} + + +async def test_battery_power_config_two_sensors_sets_stat_rate( + hass: HomeAssistant, +) -> None: + """Test that battery with two-sensor power_config sets stat_rate.""" + manager = EnergyManager(hass) + await manager.async_initialize() + manager.data = manager.default_preferences() + + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_from": "sensor.battery_discharge", + "stat_rate_to": "sensor.battery_charge", + }, + } + ], + } + ) + + assert manager.data is not None + source = manager.data["energy_sources"][0] + # Entity ID includes discharge sensor name to avoid collisions + assert ( + source["stat_rate"] + == "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + + +async def test_grid_power_config_inverted_sets_stat_rate( + hass: HomeAssistant, +) -> None: + """Test that grid with inverted power_config sets stat_rate.""" + manager = EnergyManager(hass) + await manager.async_initialize() + manager.data = manager.default_preferences() + + await manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "power_config": { + "stat_rate_inverted": "sensor.grid_power", + }, + } + ], + "cost_adjustment_day": 0, + } + ], + } + ) + + assert manager.data is not None + grid_source = manager.data["energy_sources"][0] + assert grid_source["power"][0]["stat_rate"] == "sensor.grid_power_inverted" + + +async def test_power_config_standard_uses_stat_rate_directly( + hass: HomeAssistant, +) -> None: + """Test that power_config with standard stat_rate uses it directly.""" + manager = EnergyManager(hass) + await manager.async_initialize() + manager.data = manager.default_preferences() + + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate": "sensor.battery_power", + }, + } + ], + } + ) + + assert manager.data is not None + source = manager.data["energy_sources"][0] + # stat_rate should be set directly from power_config.stat_rate + assert source["stat_rate"] == "sensor.battery_power" + + +async def test_battery_without_power_config_unchanged(hass: HomeAssistant) -> None: + """Test that battery without power_config is unchanged.""" + manager = EnergyManager(hass) + await manager.async_initialize() + manager.data = manager.default_preferences() + + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "stat_rate": "sensor.battery_power", + } + ], + } + ) + + assert manager.data is not None + source = manager.data["energy_sources"][0] + assert source["stat_rate"] == "sensor.battery_power" + assert "power_config" not in source + + +async def test_power_config_takes_precedence_over_stat_rate( + hass: HomeAssistant, +) -> None: + """Test that power_config takes precedence when both are provided.""" + manager = EnergyManager(hass) + await manager.async_initialize() + manager.data = manager.default_preferences() + + # Frontend sends both stat_rate and power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "stat_rate": "sensor.battery_power", # This should be ignored + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + + assert manager.data is not None + source = manager.data["energy_sources"][0] + # stat_rate should be overwritten to point to the generated inverted sensor + assert source["stat_rate"] == "sensor.battery_power_inverted" + + +async def test_power_config_validation_empty() -> None: + """Test that empty power_config raises validation error.""" + with pytest.raises(vol.Invalid, match="power_config must have at least one option"): + POWER_CONFIG_SCHEMA({}) + + +async def test_power_config_validation_multiple_methods() -> None: + """Test that power_config with multiple methods raises validation error.""" + # Both stat_rate and stat_rate_inverted (should fail due to Exclusive) + with pytest.raises(vol.Invalid): + POWER_CONFIG_SCHEMA( + { + "stat_rate": "sensor.power", + "stat_rate_inverted": "sensor.power", + } + ) + + # Both stat_rate and stat_rate_from/to (should fail due to Exclusive) + with pytest.raises(vol.Invalid): + POWER_CONFIG_SCHEMA( + { + "stat_rate": "sensor.power", + "stat_rate_from": "sensor.discharge", + "stat_rate_to": "sensor.charge", + } + ) + + # Both stat_rate_inverted and stat_rate_from/to (should fail due to Exclusive) + with pytest.raises(vol.Invalid): + POWER_CONFIG_SCHEMA( + { + "stat_rate_inverted": "sensor.power", + "stat_rate_from": "sensor.discharge", + "stat_rate_to": "sensor.charge", + } + ) + + +async def test_flow_from_validation_multiple_prices() -> None: + """Test that flow_from validation rejects both entity and number price.""" + # Both entity_energy_price and number_energy_price should fail + with pytest.raises( + vol.Invalid, match="Define either an entity or a fixed number for the price" + ): + FLOW_FROM_GRID_SOURCE_SCHEMA( + { + "stat_energy_from": "sensor.energy", + "entity_energy_price": "sensor.price", + "number_energy_price": 0.15, + } + ) + + +async def test_energy_sources_validation_multiple_grids() -> None: + """Test that multiple grid sources are rejected.""" + # Multiple grid sources should fail validation + with pytest.raises(vol.Invalid, match="You cannot have more than 1 grid source"): + ENERGY_SOURCE_SCHEMA( + [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "cost_adjustment_day": 0, + }, + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "cost_adjustment_day": 0, + }, + ] + ) + + +async def test_power_config_validation_passes() -> None: + """Test that valid power_config passes validation.""" + # Test standard stat_rate + result = POWER_CONFIG_SCHEMA({"stat_rate": "sensor.power"}) + assert result == {"stat_rate": "sensor.power"} + + # Test inverted + result = POWER_CONFIG_SCHEMA({"stat_rate_inverted": "sensor.power"}) + assert result == {"stat_rate_inverted": "sensor.power"} + + # Test two-sensor combined + result = POWER_CONFIG_SCHEMA( + {"stat_rate_from": "sensor.discharge", "stat_rate_to": "sensor.charge"} + ) + assert result == { + "stat_rate_from": "sensor.discharge", + "stat_rate_to": "sensor.charge", + } + + +async def test_grid_power_config_standard_stat_rate(hass: HomeAssistant) -> None: + """Test that grid with power_config using standard stat_rate works correctly.""" + manager = EnergyManager(hass) + await manager.async_initialize() + manager.data = manager.default_preferences() + + await manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + "power_config": { + "stat_rate": "sensor.grid_power", + }, + } + ], + "cost_adjustment_day": 0, + } + ], + } + ) + + assert manager.data is not None + grid_source = manager.data["energy_sources"][0] + # stat_rate should be set directly from power_config.stat_rate + assert grid_source["power"][0]["stat_rate"] == "sensor.grid_power" + + +async def test_flow_from_duplicate_stat_energy_from() -> None: + """Test that duplicate stat_energy_from values are rejected.""" + with pytest.raises( + vol.Invalid, match="Cannot specify sensor.energy more than once" + ): + ENERGY_SOURCE_SCHEMA( + [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.15, + }, + { + "stat_energy_from": "sensor.energy", # Duplicate + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.20, + }, + ], + "flow_to": [], + "cost_adjustment_day": 0, + }, + ] + ) + + +async def test_async_update_when_data_is_none(hass: HomeAssistant) -> None: + """Test async_update when manager.data is None uses default preferences.""" + manager = EnergyManager(hass) + await manager.async_initialize() + + # Ensure data is None (empty store) + assert manager.data is None + + # Call async_update - should use default_preferences as base + await manager.async_update( + { + "energy_sources": [ + { + "type": "solar", + "stat_energy_from": "sensor.solar_energy", + "config_entry_solar_forecast": None, + } + ], + } + ) + + # Verify data was created with the update and default fields + assert manager.data is not None + assert len(manager.data["energy_sources"]) == 1 + assert manager.data["energy_sources"][0]["type"] == "solar" + # Default fields should be present + assert manager.data["device_consumption"] == [] + assert manager.data["device_consumption_water"] == [] + + +async def test_grid_power_without_power_config(hass: HomeAssistant) -> None: + """Test that grid power entry without power_config is preserved unchanged.""" + manager = EnergyManager(hass) + await manager.async_initialize() + manager.data = manager.default_preferences() + + await manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [], + "flow_to": [], + "power": [ + { + # No power_config, just stat_rate directly + "stat_rate": "sensor.grid_power", + } + ], + "cost_adjustment_day": 0, + } + ], + } + ) + + assert manager.data is not None + grid_source = manager.data["energy_sources"][0] + # Power entry should be preserved unchanged + assert len(grid_source["power"]) == 1 + assert grid_source["power"][0]["stat_rate"] == "sensor.grid_power" + assert "power_config" not in grid_source["power"][0] diff --git a/tests/components/energy/test_helpers.py b/tests/components/energy/test_helpers.py new file mode 100644 index 00000000000..b6abcaee899 --- /dev/null +++ b/tests/components/energy/test_helpers.py @@ -0,0 +1,111 @@ +"""Test the Energy helpers.""" + +import pytest + +from homeassistant.components.energy.helpers import ( + generate_power_sensor_entity_id, + generate_power_sensor_unique_id, +) + + +def test_generate_power_sensor_unique_id_inverted() -> None: + """Test unique ID generation for inverted power config.""" + config = {"stat_rate_inverted": "sensor.battery_power"} + unique_id = generate_power_sensor_unique_id("battery", config) + assert unique_id == "energy_power_battery_inverted_sensor_battery_power" + + +def test_generate_power_sensor_unique_id_combined() -> None: + """Test unique ID generation for combined power config.""" + config = { + "stat_rate_from": "sensor.battery_discharge", + "stat_rate_to": "sensor.battery_charge", + } + unique_id = generate_power_sensor_unique_id("battery", config) + assert ( + unique_id + == "energy_power_battery_combined_sensor_battery_discharge_sensor_battery_charge" + ) + + +def test_generate_power_sensor_unique_id_standard() -> None: + """Test unique ID generation raises for standard config (schema-invalid).""" + config = {"stat_rate": "sensor.battery_power"} + with pytest.raises(RuntimeError, match="Invalid power config"): + generate_power_sensor_unique_id("battery", config) + + +def test_generate_power_sensor_unique_id_empty() -> None: + """Test unique ID generation raises for empty config (schema-invalid).""" + config = {} + with pytest.raises(RuntimeError, match="Invalid power config"): + generate_power_sensor_unique_id("battery", config) + + +def test_generate_power_sensor_unique_id_grid() -> None: + """Test unique ID generation for grid source type.""" + config = {"stat_rate_inverted": "sensor.grid_power"} + unique_id = generate_power_sensor_unique_id("grid", config) + assert unique_id == "energy_power_grid_inverted_sensor_grid_power" + + +def test_generate_power_sensor_entity_id_inverted_with_prefix() -> None: + """Test entity ID generation for inverted config with sensor prefix.""" + config = {"stat_rate_inverted": "sensor.battery_power"} + entity_id = generate_power_sensor_entity_id("battery", config) + assert entity_id == "sensor.battery_power_inverted" + + +def test_generate_power_sensor_entity_id_inverted_without_prefix() -> None: + """Test entity ID generation for inverted config without sensor prefix.""" + config = {"stat_rate_inverted": "custom.battery_power"} + entity_id = generate_power_sensor_entity_id("battery", config) + assert entity_id == "sensor.custom_battery_power_inverted" + + +def test_generate_power_sensor_entity_id_combined() -> None: + """Test entity ID generation for combined power config.""" + config = { + "stat_rate_from": "sensor.battery_discharge", + "stat_rate_to": "sensor.battery_charge", + } + entity_id = generate_power_sensor_entity_id("battery", config) + assert ( + entity_id == "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + + +def test_generate_power_sensor_entity_id_combined_without_prefix() -> None: + """Test entity ID generation for combined config without sensor prefix.""" + config = { + "stat_rate_from": "battery_discharge", + "stat_rate_to": "battery_charge", + } + entity_id = generate_power_sensor_entity_id("battery", config) + assert ( + entity_id == "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + + +def test_generate_power_sensor_entity_id_standard() -> None: + """Test entity ID generation raises for standard config (schema-invalid).""" + config = {"stat_rate": "sensor.battery_power"} + with pytest.raises(RuntimeError, match="Invalid power config"): + generate_power_sensor_entity_id("battery", config) + + +def test_generate_power_sensor_entity_id_empty() -> None: + """Test entity ID generation raises for empty config (schema-invalid).""" + config = {} + with pytest.raises(RuntimeError, match="Invalid power config"): + generate_power_sensor_entity_id("battery", config) + + +def test_generate_power_sensor_entity_id_grid() -> None: + """Test entity ID generation for grid source type.""" + config = { + "stat_rate_from": "sensor.grid_import", + "stat_rate_to": "sensor.grid_export", + } + entity_id = generate_power_sensor_entity_id("grid", config) + assert entity_id == "sensor.energy_grid_grid_import_grid_export_net_power" diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 01f57a88085..434bf3c07f8 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -8,7 +8,13 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.energy import data +from homeassistant.components.energy import async_get_manager, data +from homeassistant.components.energy.sensor import ( + EnergyCostSensor, + EnergyPowerSensor, + SensorManager, + SourceAdapter, +) from homeassistant.components.recorder.core import Recorder from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import ( @@ -25,15 +31,17 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, UnitOfEnergy, + UnitOfPower, UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import _WH_TO_CAL, _WH_TO_J from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM +from tests.common import MockConfigEntry from tests.components.recorder.common import async_wait_recording_done from tests.typing import WebSocketGenerator @@ -1324,3 +1332,1381 @@ async def test_inherit_source_unique_id( assert entry assert entry.unique_id == f"{source_entry.id}_gas_cost" assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION + + +async def test_needs_power_sensor_standard(hass: HomeAssistant) -> None: + """Test _needs_power_sensor returns False for standard stat_rate.""" + assert SensorManager._needs_power_sensor({"stat_rate": "sensor.power"}) is False + + +async def test_needs_power_sensor_inverted(hass: HomeAssistant) -> None: + """Test _needs_power_sensor returns True for inverted config.""" + assert ( + SensorManager._needs_power_sensor({"stat_rate_inverted": "sensor.power"}) + is True + ) + + +async def test_needs_power_sensor_combined(hass: HomeAssistant) -> None: + """Test _needs_power_sensor returns True for combined config.""" + assert ( + SensorManager._needs_power_sensor( + { + "stat_rate_from": "sensor.discharge", + "stat_rate_to": "sensor.charge", + } + ) + is True + ) + + +async def test_needs_power_sensor_partial_combined(hass: HomeAssistant) -> None: + """Test _needs_power_sensor returns False for incomplete combined config.""" + # Only stat_rate_from without stat_rate_to + assert ( + SensorManager._needs_power_sensor({"stat_rate_from": "sensor.discharge"}) + is False + ) + + +async def test_power_sensor_manager_creation( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test SensorManager creates power sensors correctly.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up a source sensor + hass.states.async_set( + "sensor.battery_power", + "100.0", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + await hass.async_block_till_done() + + # Update with battery that has inverted power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify the power sensor entity was created + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert float(state.state) == -100.0 + + +async def test_power_sensor_manager_cleanup( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test SensorManager removes power sensors when config changes.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up source sensors + hass.states.async_set("sensor.battery_power", "100.0") + await hass.async_block_till_done() + + # Create with inverted power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify sensor exists and has a valid value + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert state.state == "-100.0" + + # Update to remove power_config (use direct stat_rate) + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "stat_rate": "sensor.battery_power", + } + ], + } + ) + await hass.async_block_till_done() + + # Verify sensor becomes unavailable when entity is removed + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert state.state == "unavailable" + + +async def test_power_sensor_grid_combined( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test power sensor for grid with combined config.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up source sensors + hass.states.async_set( + "sensor.grid_import", + "500.0", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + hass.states.async_set( + "sensor.grid_export", + "200.0", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + await hass.async_block_till_done() + + # Update with grid that has combined power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.grid_energy_import", + } + ], + "flow_to": [ + { + "stat_energy_to": "sensor.grid_energy_export", + } + ], + "power": [ + { + "power_config": { + "stat_rate_from": "sensor.grid_import", + "stat_rate_to": "sensor.grid_export", + } + } + ], + "cost_adjustment_day": 0, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify the power sensor entity was created + state = hass.states.get("sensor.energy_grid_grid_import_grid_export_net_power") + assert state is not None + # 500 - 200 = 300 (net import) + assert float(state.state) == 300.0 + + +async def test_power_sensor_device_assignment( + recorder_mock: Recorder, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test power sensor is assigned to same device as source sensor.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Create a config entry for the device + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + # Create a device and register source sensor to it + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "battery_device")}, + name="Battery Device", + ) + + # Register the source sensor with the device + entity_registry.async_get_or_create( + "sensor", + "test", + "battery_power", + suggested_object_id="battery_power", + device_id=device_entry.id, + ) + + # Set up source sensor state + hass.states.async_set( + "sensor.battery_power", + "100.0", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + await hass.async_block_till_done() + + # Update with battery that has inverted power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify the power sensor was created and assigned to same device + power_sensor_entry = entity_registry.async_get("sensor.battery_power_inverted") + assert power_sensor_entry is not None + assert power_sensor_entry.device_id == device_entry.id + + +async def test_power_sensor_device_assignment_combined_second_sensor( + recorder_mock: Recorder, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test power sensor checks second sensor if first has no device.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Create a config entry for the device + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + # Create a device and register second sensor to it + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "battery_device")}, + name="Battery Device", + ) + + # Register first sensor WITHOUT device + entity_registry.async_get_or_create( + "sensor", + "test", + "battery_discharge", + suggested_object_id="battery_discharge", + ) + + # Register second sensor WITH device + entity_registry.async_get_or_create( + "sensor", + "test", + "battery_charge", + suggested_object_id="battery_charge", + device_id=device_entry.id, + ) + + # Set up source sensor states + hass.states.async_set( + "sensor.battery_discharge", + "100.0", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + hass.states.async_set( + "sensor.battery_charge", + "50.0", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + await hass.async_block_till_done() + + # Update with battery that has combined power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_from": "sensor.battery_discharge", + "stat_rate_to": "sensor.battery_charge", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify the power sensor was created and assigned to second sensor's device + power_sensor_entry = entity_registry.async_get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert power_sensor_entry is not None + assert power_sensor_entry.device_id == device_entry.id + + +async def test_power_sensor_inverted_availability( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test inverted power sensor availability follows source sensor.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up source sensor as available + hass.states.async_set("sensor.battery_power", "100.0") + await hass.async_block_till_done() + + # Configure battery with inverted power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Power sensor should be available + state = hass.states.get("sensor.battery_power_inverted") + assert state + assert state.state == "-100.0" + + # Make source unavailable + hass.states.async_set("sensor.battery_power", "unavailable") + await hass.async_block_till_done() + + # Power sensor should become unavailable + state = hass.states.get("sensor.battery_power_inverted") + assert state + assert state.state == "unavailable" + + # Make source available again + hass.states.async_set("sensor.battery_power", "50.0") + await hass.async_block_till_done() + + # Power sensor should become available again + state = hass.states.get("sensor.battery_power_inverted") + assert state + assert state.state == "-50.0" + + +async def test_power_sensor_combined_availability( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test combined power sensor availability requires both sources available.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up both source sensors as available + hass.states.async_set("sensor.battery_discharge", "150.0") + hass.states.async_set("sensor.battery_charge", "50.0") + await hass.async_block_till_done() + + # Configure battery with combined power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_from": "sensor.battery_discharge", + "stat_rate_to": "sensor.battery_charge", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Power sensor should be available and show net power + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state + assert state.state == "100.0" + + # Make first source unavailable + hass.states.async_set("sensor.battery_discharge", "unavailable") + await hass.async_block_till_done() + + # Power sensor should become unavailable + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state + assert state.state == "unavailable" + + # Make first source available again + hass.states.async_set("sensor.battery_discharge", "200.0") + await hass.async_block_till_done() + + # Power sensor should become available again + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state + assert state.state == "150.0" + + # Make second source unavailable + hass.states.async_set("sensor.battery_charge", "unknown") + await hass.async_block_till_done() + + # Power sensor should become unavailable again + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state + assert state.state == "unavailable" + + +async def test_power_sensor_battery_combined( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test power sensor for battery with combined config.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up source sensors + hass.states.async_set( + "sensor.battery_discharge", + "150.0", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + hass.states.async_set( + "sensor.battery_charge", + "50.0", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + await hass.async_block_till_done() + + # Update with battery that has combined power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_from": "sensor.battery_discharge", + "stat_rate_to": "sensor.battery_charge", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify the power sensor entity was created + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state is not None + # 150 - 50 = 100 (net discharging) + assert float(state.state) == 100.0 + + # Test net charging scenario + hass.states.async_set("sensor.battery_discharge", "30.0") + hass.states.async_set("sensor.battery_charge", "80.0") + await hass.async_block_till_done() + + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state is not None + # 30 - 80 = -50 (net charging) + assert float(state.state) == -50.0 + + +async def test_power_sensor_combined_unit_conversion( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test power sensor combined mode with different units.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up source sensors with different units (kW and W) + hass.states.async_set( + "sensor.battery_discharge", + "1.5", # 1.5 kW = 1500 W + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + ) + hass.states.async_set( + "sensor.battery_charge", + "500.0", # 500 W + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + await hass.async_block_till_done() + + # Update with battery that has combined power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_from": "sensor.battery_discharge", + "stat_rate_to": "sensor.battery_charge", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify the power sensor converts units properly + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state is not None + # 1500 W - 500 W = 1000 W (units are converted to W internally) + assert float(state.state) == 1000.0 + + +async def test_power_sensor_inverted_negative_values( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test inverted power sensor with negative source values.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up source sensor with positive value + hass.states.async_set( + "sensor.battery_power", + "100.0", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.WATT}, + ) + await hass.async_block_till_done() + + # Update with battery that has inverted power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify inverted value + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert float(state.state) == -100.0 + + # Update source to negative value (should become positive) + hass.states.async_set("sensor.battery_power", "-50.0") + await hass.async_block_till_done() + + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert float(state.state) == 50.0 + + +async def test_energy_data_removal( + recorder_mock: Recorder, hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test that cost sensors are removed when energy data is cleared.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 1, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + hass.states.async_set( + "sensor.energy_consumption", + "100", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + + assert await async_setup_component(hass, "energy", {"energy": {}}) + await hass.async_block_till_done() + + # Verify cost sensor was created + state = hass.states.get("sensor.energy_consumption_cost") + assert state is not None + assert state.state == "0.0" + + # Clear all energy data + manager = await async_get_manager(hass) + await manager.async_update({"energy_sources": []}) + await hass.async_block_till_done() + + # Verify cost sensor becomes unavailable + state = hass.states.get("sensor.energy_consumption_cost") + assert state is not None + assert state.state == "unavailable" + + +async def test_stat_cost_already_configured( + setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test that no cost sensor is created when stat_cost is already configured.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": "sensor.existing_cost", # Cost already configured + "entity_energy_price": None, + "number_energy_price": 1, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + hass.states.async_set( + "sensor.energy_consumption", + "100", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + hass.states.async_set("sensor.existing_cost", "50.0") + + await setup_integration(hass) + + # Verify no cost sensor was created (since stat_cost is configured) + state = hass.states.get("sensor.energy_consumption_cost") + assert state is None + + +async def test_invalid_energy_state( + setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test handling of invalid energy state value.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 1, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + # Set energy sensor with valid initial state + hass.states.async_set( + "sensor.energy_consumption", + "100", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Update with invalid value + hass.states.async_set( + "sensor.energy_consumption", + "not_a_number", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + await hass.async_block_till_done() + + # Cost should remain unchanged + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + +async def test_invalid_energy_unit( + setup_integration, + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of invalid energy unit.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 1, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + # Set energy sensor with valid state + hass.states.async_set( + "sensor.energy_consumption", + "100", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Update with invalid unit + hass.states.async_set( + "sensor.energy_consumption", + "200", + { + ATTR_UNIT_OF_MEASUREMENT: "invalid_unit", + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + await hass.async_block_till_done() + + # Cost should remain unchanged and warning should be logged + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + assert "Found unexpected unit invalid_unit" in caplog.text + + # Update again with same invalid unit - should not log again + caplog.clear() + hass.states.async_set( + "sensor.energy_consumption", + "300", + { + ATTR_UNIT_OF_MEASUREMENT: "invalid_unit", + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + await hass.async_block_till_done() + + # No new warning should be logged (already warned once) + assert "Found unexpected unit" not in caplog.text + + +async def test_no_energy_unit( + setup_integration, + hass: HomeAssistant, + hass_storage: dict[str, Any], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of missing energy unit.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 1, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + # Set energy sensor with valid state + hass.states.async_set( + "sensor.energy_consumption", + "100", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Update with no unit + hass.states.async_set( + "sensor.energy_consumption", + "200", + {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, + ) + await hass.async_block_till_done() + + # Cost should remain unchanged and warning should be logged + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + assert "Found unexpected unit None" in caplog.text + + +async def test_power_sensor_inverted_invalid_value( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test inverted power sensor with invalid source value.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up source sensor with valid value + hass.states.async_set("sensor.battery_power", "100.0") + await hass.async_block_till_done() + + # Configure battery with inverted power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Power sensor should be available + state = hass.states.get("sensor.battery_power_inverted") + assert state + assert state.state == "-100.0" + + # Update source to invalid value + hass.states.async_set("sensor.battery_power", "not_a_number") + await hass.async_block_till_done() + + # Power sensor should have unknown state (value is None) + state = hass.states.get("sensor.battery_power_inverted") + assert state + assert state.state == "unknown" + + +async def test_power_sensor_combined_invalid_value( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test combined power sensor with invalid source value.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up both source sensors as valid + hass.states.async_set("sensor.battery_discharge", "150.0") + hass.states.async_set("sensor.battery_charge", "50.0") + await hass.async_block_till_done() + + # Configure battery with combined power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_from": "sensor.battery_discharge", + "stat_rate_to": "sensor.battery_charge", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Power sensor should be available + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state + assert state.state == "100.0" + + # Update first source to invalid value + hass.states.async_set("sensor.battery_discharge", "invalid") + await hass.async_block_till_done() + + # Power sensor should have unknown state (value is None) + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state + assert state.state == "unknown" + + # Restore first source + hass.states.async_set("sensor.battery_discharge", "150.0") + await hass.async_block_till_done() + + # Power sensor should work again + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state + assert state.state == "100.0" + + # Make second source invalid + hass.states.async_set("sensor.battery_charge", "not_a_number") + await hass.async_block_till_done() + + # Power sensor should have unknown state + state = hass.states.get( + "sensor.energy_battery_battery_discharge_battery_charge_net_power" + ) + assert state + assert state.state == "unknown" + + +async def test_power_sensor_naming_fallback( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test power sensor naming when source not in registry.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Set up source sensor WITHOUT registering it in entity registry + hass.states.async_set("sensor.battery_power", "100.0") + await hass.async_block_till_done() + + # Configure battery with inverted power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify sensor was created with fallback naming + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + # Name should be based on entity_id since not in registry + assert state.attributes["friendly_name"] == "Battery Power Inverted" + + +async def test_power_sensor_no_device_assignment( + recorder_mock: Recorder, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test power sensor when source sensors have no device.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Register source sensors WITHOUT device + entity_registry.async_get_or_create( + "sensor", + "test", + "battery_power", + suggested_object_id="battery_power", + ) + + # Set up source sensor state + hass.states.async_set("sensor.battery_power", "100.0") + await hass.async_block_till_done() + + # Update with battery that has inverted power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify the power sensor was created without device + power_sensor_entry = entity_registry.async_get("sensor.battery_power_inverted") + assert power_sensor_entry is not None + assert power_sensor_entry.device_id is None + + +async def test_power_sensor_keeps_existing_on_update( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test that existing power sensor is kept when config doesn't change.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + hass.states.async_set("sensor.battery_power", "100.0") + await hass.async_block_till_done() + + # Create initial config + config = { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + await manager.async_update(config) + await hass.async_block_till_done() + + # Verify power sensor exists + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert state.state == "-100.0" + + # Update source value + hass.states.async_set("sensor.battery_power", "200.0") + await hass.async_block_till_done() + + # Update manager with same config (should keep existing sensor) + await manager.async_update(config) + await hass.async_block_till_done() + + # Verify sensor still exists with updated value + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert state.state == "-200.0" + + +async def test_invalid_price_entity_value( + setup_integration, + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test handling of invalid energy price entity value.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": "sensor.energy_price", + "number_energy_price": None, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + # Set up energy sensor + hass.states.async_set( + "sensor.energy_consumption", + "100", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + + # Set up price sensor with invalid value + hass.states.async_set("sensor.energy_price", "not_a_number") + + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Update energy consumption - cost should not change due to invalid price + hass.states.async_set( + "sensor.energy_consumption", + "200", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + await hass.async_block_till_done() + + # Cost should remain at 0.0 because price is invalid + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + +async def test_power_sensor_naming_with_registry_name( + recorder_mock: Recorder, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test power sensor naming uses registry name when available.""" + assert await async_setup_component(hass, "energy", {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + # Register source sensor WITH a name + entity_registry.async_get_or_create( + "sensor", + "test", + "battery_power", + suggested_object_id="battery_power", + original_name="My Battery Power", + ) + + # Set up source sensor state + hass.states.async_set("sensor.battery_power", "100.0") + await hass.async_block_till_done() + + # Configure battery with inverted power_config + await manager.async_update( + { + "energy_sources": [ + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": { + "stat_rate_inverted": "sensor.battery_power", + }, + } + ], + } + ) + await hass.async_block_till_done() + + # Verify sensor was created with registry name + state = hass.states.get("sensor.battery_power_inverted") + assert state is not None + assert state.attributes["friendly_name"] == "My Battery Power Inverted" + + +async def test_missing_price_entity( + setup_integration, + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test handling when energy price entity doesn't exist.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": "sensor.nonexistent_price", + "number_energy_price": None, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + # Set up energy sensor only (price sensor doesn't exist) + hass.states.async_set( + "sensor.energy_consumption", + "100", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + + await setup_integration(hass) + + # When price entity doesn't exist initially, sensor stays unknown + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == STATE_UNKNOWN + + # Now create the price entity + hass.states.async_set("sensor.nonexistent_price", "1.5") + await hass.async_block_till_done() + + # Update energy consumption - should initialize now that price exists + hass.states.async_set( + "sensor.energy_consumption", + "200", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + await hass.async_block_till_done() + + # Cost should be initialized (0.0 because it's the first update after price became available) + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Update consumption again - now cost should increase + hass.states.async_set( + "sensor.energy_consumption", + "300", + { + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, + ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, + }, + ) + await hass.async_block_till_done() + + # Cost should be 150.0 (100 kWh * 1.5 EUR/kWh) + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "150.0" + + +async def test_energy_cost_sensor_add_to_platform_abort( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test EnergyCostSensor.add_to_platform_abort sets the future.""" + adapter = SourceAdapter( + source_type="grid", + flow_type="flow_from", + stat_energy_key="stat_energy_from", + total_money_key="stat_cost", + name_suffix="Cost", + entity_id_suffix="cost", + ) + config = { + "stat_energy_from": "sensor.energy", + "stat_cost": None, + "entity_energy_price": "sensor.price", + "number_energy_price": None, + } + + sensor = EnergyCostSensor(adapter, config) + + # Future should not be done yet + assert not sensor.add_finished.done() + + # Call abort + sensor.add_to_platform_abort() + + # Future should now be done + assert sensor.add_finished.done() + + +async def test_energy_power_sensor_add_to_platform_abort( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test EnergyPowerSensor.add_to_platform_abort sets the future.""" + sensor = EnergyPowerSensor( + source_type="battery", + config={"stat_rate_inverted": "sensor.battery_power"}, + unique_id="test_unique_id", + entity_id="sensor.test_power", + ) + + # Future should not be done yet + assert not sensor.add_finished.done() + + # Call abort + sensor.add_to_platform_abort() + + # Future should now be done + assert sensor.add_finished.done()