Improve handling of renamed energy power sensor (#183852)

This commit is contained in:
Erik Montnemery
2026-10-01 15:26:28 +03:00
committed by GitHub
parent b6b37bfeca
commit 5fc2455f93
3 changed files with 256 additions and 19 deletions
+70 -10
View File
@@ -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
+45 -8
View File
@@ -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:
+141 -1
View File
@@ -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"
)