From 5fc2455f936fd5886cd1daefa1d8a89571c5324d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Oct 2026 13:26:28 +0100 Subject: [PATCH] Improve handling of renamed energy power sensor (#183852) --- homeassistant/components/energy/data.py | 80 +++++++++++-- tests/components/energy/test_data.py | 53 +++++++-- tests/components/energy/test_sensor.py | 142 +++++++++++++++++++++++- 3 files changed, 256 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py index 627e05e8ff74..0e95fa8e5603 100644 --- a/homeassistant/components/energy/data.py +++ b/homeassistant/components/energy/data.py @@ -7,8 +7,14 @@ from typing import Any, Literal, NotRequired, TypedDict, override import probatio -from homeassistant.core import HomeAssistant, callback, valid_entity_id -from homeassistant.helpers import config_validation as cv, singleton, storage +from homeassistant.const import Platform +from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + singleton, + storage, +) from .const import DOMAIN @@ -766,6 +772,46 @@ class EnergyManager: async def async_initialize(self) -> None: """Initialize the energy integration.""" self.data = await self._store.async_load() + # The power sensors may have been renamed while energy was not loaded + self._async_refresh_power_stat_rates() + self._hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + self._async_entity_registry_updated, + event_filter=self._async_filter_power_sensor_registry_updated, + ) + + @callback + def _async_filter_power_sensor_registry_updated( + self, event_data: er.EventEntityRegistryUpdatedData + ) -> bool: + """Filter for power sensors being created or getting a new entity_id.""" + if event_data["action"] == "update": + if "entity_id" not in event_data["changes"]: + return False + elif event_data["action"] != "create": + return False + entry = er.async_get(self._hass).async_get(event_data["entity_id"]) + return entry is not None and entry.platform == DOMAIN + + @callback + def _async_entity_registry_updated( + self, event: Event[er.EventEntityRegistryUpdatedData] + ) -> None: + """Handle a power sensor being created or getting a new entity_id.""" + self._async_refresh_power_stat_rates() + + @callback + def _async_refresh_power_stat_rates(self) -> None: + """Point stat_rate of power transform configs at the actual entity_id.""" + if self.data is None: + return + energy_sources = self._process_energy_sources(self.data["energy_sources"]) + if energy_sources == self.data["energy_sources"]: + return + data = self.data.copy() + data["energy_sources"] = energy_sources + self.data = data + self._store.async_delay_save(lambda: data, 60) @staticmethod def default_preferences() -> EnergyPreferences: @@ -805,20 +851,34 @@ class EnergyManager: await asyncio.gather(*(listener() for listener in self._update_listeners)) + def _power_sensor_entity_id(self, source_type: str, config: PowerConfig) -> str: + """Return the entity_id of a power transform sensor. + + The generated entity_id is only a suggestion, the sensor may have been + renamed or registered with another entity_id. + """ + from .helpers import ( # noqa: PLC0415 + generate_power_sensor_entity_id, + generate_power_sensor_unique_id, + ) + + unique_id = generate_power_sensor_unique_id(source_type, config) + if entity_id := er.async_get(self._hass).async_get_entity_id( + Platform.SENSOR, DOMAIN, unique_id + ): + return entity_id + return generate_power_sensor_entity_id(source_type, config) + 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 + source, self._power_sensor_entity_id ) elif source["type"] == "grid": - source = self._process_grid_power( - source, generate_power_sensor_entity_id - ) + source = self._process_grid_power(source, self._power_sensor_entity_id) processed.append(source) return processed @@ -837,7 +897,7 @@ class EnergyManager: 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 + # For inverted or two-sensor config, set stat_rate to the power sensor return {**source, "stat_rate": generate_entity_id("battery", config)} def _process_grid_power( @@ -855,7 +915,7 @@ class EnergyManager: 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 + # For inverted or two-sensor config, set stat_rate to the power sensor return {**source, "stat_rate": generate_entity_id("grid", config)} @callback diff --git a/tests/components/energy/test_data.py b/tests/components/energy/test_data.py index cfa7f827528b..6d74ca5dae1c 100644 --- a/tests/components/energy/test_data.py +++ b/tests/components/energy/test_data.py @@ -1,5 +1,7 @@ """Test energy data storage and migration.""" +from typing import Any + import probatio import pytest @@ -13,7 +15,7 @@ from homeassistant.components.energy.data import ( EnergyManager, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import storage +from homeassistant.helpers import entity_registry as er, storage async def test_energy_preferences_no_migration_needed(hass: HomeAssistant) -> None: @@ -42,6 +44,44 @@ async def test_energy_preferences_no_migration_needed(hass: HomeAssistant) -> No ] +async def test_energy_preferences_load_resolves_renamed_power_sensor( + hass: HomeAssistant, + hass_storage: dict[str, Any], + entity_registry: er.EntityRegistry, +) -> None: + """Test a stale stat_rate is pointed at the renamed power sensor on load.""" + entity_registry.async_get_or_create( + "sensor", + "energy", + "energy_power_battery_inverted_sensor_battery_power", + suggested_object_id="renamed_power", + ) + hass_storage["energy"] = { + "version": 1, + "minor_version": 3, + "key": "energy", + "data": { + "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"}, + "stat_rate": "sensor.battery_power_inverted", + } + ], + "device_consumption": [], + "device_consumption_water": [], + }, + } + + manager = EnergyManager(hass) + await manager.async_initialize() + + assert manager.data is not None + assert manager.data["energy_sources"][0]["stat_rate"] == "sensor.renamed_power" + + async def test_energy_preferences_default(hass: HomeAssistant) -> None: """Test default preferences include device_consumption_water.""" defaults = EnergyManager.default_preferences() @@ -727,11 +767,10 @@ async def test_grid_migration_more_imports_than_exports(hass: HomeAssistant) -> async def test_grid_migration_with_power(hass: HomeAssistant) -> None: - """Test migration preserves power config and stat_rate from first grid. + """Test migration preserves power config from first grid. - Note: Migration preserves the original stat_rate value from the legacy power array. - The stat_rate regeneration from power_config only happens during async_update() - for new data submissions, not during storage migration. + Note: stat_rate is regenerated from power_config when the preferences are + loaded, so a stale stat_rate from the legacy power array is replaced. """ old_data = { "energy_sources": [ @@ -768,9 +807,7 @@ async def test_grid_migration_with_power(hass: HomeAssistant) -> None: # Verify power_config is preserved assert grid["power_config"] == {"stat_rate_inverted": "sensor.grid_power"} - # Migration preserves the original stat_rate value from the legacy power array - # (stat_rate regeneration from power_config only happens in async_update) - assert grid["stat_rate"] == "sensor.grid_power" + assert grid["stat_rate"] == "sensor.grid_power_inverted" async def test_grid_migration_import_only(hass: HomeAssistant) -> None: diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index dc418857dc71..42f356ce30c9 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -41,7 +41,7 @@ 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.common import MockConfigEntry, async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done from tests.typing import WebSocketGenerator @@ -2807,3 +2807,143 @@ async def test_add_finished_resolved_on_abort( sensor.add_to_platform_abort() assert sensor.add_finished.done() + + +@pytest.mark.parametrize( + ("energy_source", "power_entity_id"), + [ + pytest.param( + { + "type": "battery", + "stat_energy_from": "sensor.battery_energy_from", + "stat_energy_to": "sensor.battery_energy_to", + "power_config": {"stat_rate_inverted": "sensor.battery_power"}, + }, + "sensor.battery_power_inverted", + id="battery_inverted", + ), + pytest.param( + { + "type": "grid", + "stat_energy_from": "sensor.grid_energy_import", + "stat_energy_to": "sensor.grid_energy_export", + "power_config": { + "stat_rate_from": "sensor.grid_import", + "stat_rate_to": "sensor.grid_export", + }, + "cost_adjustment_day": 0, + }, + "sensor.energy_grid_grid_import_grid_export_net_power", + id="grid_combined", + ), + ], +) +async def test_power_sensor_rename_updates_stat_rate( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_storage: dict[str, Any], + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + energy_source: data.SourceType, + power_entity_id: str, +) -> None: + """Test renaming a power sensor updates stat_rate in the preferences.""" + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + + await manager.async_update({"energy_sources": [energy_source]}) + await hass.async_block_till_done() + assert manager.data["energy_sources"][0]["stat_rate"] == power_entity_id + + entity_registry.async_update_entity( + power_entity_id, new_entity_id="sensor.renamed_power" + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.renamed_power") is not None + assert manager.data["energy_sources"][0]["stat_rate"] == "sensor.renamed_power" + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass_storage[data.STORAGE_KEY]["data"]["energy_sources"][0]["stat_rate"] + == "sensor.renamed_power" + ) + + +async def test_power_sensor_rename_other_entity_keeps_prefs( + recorder_mock: Recorder, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test renaming an entity which is not a power sensor leaves prefs alone.""" + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + entity_registry.async_get_or_create( + "sensor", "test", "battery_power", suggested_object_id="battery_power" + ) + + 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() + prefs = manager.data + + entity_registry.async_update_entity( + "sensor.battery_power", new_entity_id="sensor.battery_power_renamed" + ) + await hass.async_block_till_done() + + assert manager.data is prefs + assert prefs["energy_sources"][0]["stat_rate"] == "sensor.battery_power_inverted" + + +async def test_power_sensor_suggested_entity_id_taken( + recorder_mock: Recorder, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test stat_rate follows the power sensor when its entity_id is taken.""" + assert await async_setup_component(hass, DOMAIN, {"energy": {}}) + manager = await async_get_manager(hass) + manager.data = manager.default_preferences() + entity_registry.async_get_or_create( + "sensor", "test", "taken", suggested_object_id="battery_power_inverted" + ) + + 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() + + assert ( + entity_registry.async_get_entity_id( + "sensor", DOMAIN, "energy_power_battery_inverted_sensor_battery_power" + ) + == "sensor.battery_power_inverted_2" + ) + assert ( + manager.data["energy_sources"][0]["stat_rate"] + == "sensor.battery_power_inverted_2" + )