diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index 031b731d8daf..b3860c66d371 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, override from aiosolaredge import SolarEdge -from solaredge_web import EnergyData, SolarEdgeWeb, TimeUnit +from solaredge_web import EnergyData, SolarEdgeWeb from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import ( @@ -16,6 +16,7 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.components.recorder.statistics import ( async_add_external_statistics, + async_list_statistic_ids, get_last_statistics, statistics_during_period, ) @@ -23,7 +24,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util, snakecase +from homeassistant.util import dt as dt_util, slugify, snakecase from homeassistant.util.unit_conversion import EnergyConverter from .const import ( @@ -455,6 +456,8 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): ) self.site_id = config_entry.data[CONF_SITE_ID] self.title = config_entry.title + self._serial_to_legacy_id: dict[str, str] = {} + self._legacy_id_map_initialized = False @callback def _dummy_listener() -> None: @@ -467,13 +470,12 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): @override async def _async_update_data(self) -> None: """Fetch data from API endpoint and update statistics.""" - equipment: dict[int, dict[str, Any]] = await self.api.async_get_equipment() + equipment: dict[str, dict[str, Any]] = await self.api.async_get_equipment() + await self._async_build_legacy_id_map(equipment) # We fetch last week's data from the API and refresh # every 12h so we overwrite recent statistics. This is # intended to allow adding any corrected/updated data. - energy_data_list: list[EnergyData] = await self.api.async_get_energy_data( - TimeUnit.WEEK - ) + energy_data_list: list[EnergyData] = await self.api.async_get_energy_data() if not energy_data_list: LOGGER.warning( "No data received from SolarEdge API for site: %s", self.site_id @@ -486,9 +488,7 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): ), ) for equipment_id, equipment_data in equipment.items(): - display_name = equipment_data.get( - "displayName", f"Equipment {equipment_id}" - ) + display_name = equipment_data.get("name", f"Equipment {equipment_id}") statistic_id = self.get_statistic_id(equipment_id) statistic_metadata = StatisticMetaData( mean_type=StatisticMeanType.ARITHMETIC, @@ -501,30 +501,19 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): ) statistic_sum = last_sums[statistic_id] statistics = [] - current_hour_sum = 0.0 - current_hour_count = 0 for energy_data in energy_data_list: start_time = energy_data.start_time.replace( tzinfo=dt_util.get_default_time_zone() ) value = energy_data.values.get(equipment_id, 0.0) - current_hour_sum += value - current_hour_count += 1 - if start_time.minute != 45: - continue - # API returns data every 15 minutes; aggregate to 1-hour statistics - # when we reach the energy_data for the last 15 minutes of the hour. - current_avg = current_hour_sum / current_hour_count - statistic_sum += current_avg + statistic_sum += value statistics.append( StatisticData( - start=start_time - timedelta(minutes=45), - state=current_avg, + start=start_time, + state=value, sum=statistic_sum, ) ) - current_hour_sum = 0.0 - current_hour_count = 0 LOGGER.debug( "Adding %s statistics for %s %s", len(statistics), @@ -533,12 +522,90 @@ class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]): ) async_add_external_statistics(self.hass, statistic_metadata, statistics) - def get_statistic_id(self, equipment_id: int) -> str: - """Return the statistic ID for this equipment_id.""" + async def _async_build_legacy_id_map( + self, equipment: dict[str, dict[str, Any]] + ) -> None: + """Build a map from serial numbers to legacy numeric statistic IDs. + + The old API returned numeric equipment IDs; the new API returns serials. + To preserve existing statistics, we map each serial's name (the last + part after splitting by space, e.g. "1.1.1") to the old statistic ID + by matching the statistic name. This runs once per coordinator lifecycle. + """ + if self._legacy_id_map_initialized: + return + + all_stats = await async_list_statistic_ids(self.hass) + + prefix = f"{DOMAIN}:{self.site_id}_" + # Map the short name (e.g. "1.1.1") to the old numeric statistic ID. + # Multiple numeric IDs may share the same name, e.g. after a module + # replacement; those matches are ambiguous and skipped. + name_to_legacy: dict[str, str] = {} + ambiguous_names: set[str] = set() + for stat in all_stats: + stat_id = stat["statistic_id"] + if not stat_id.startswith(prefix): + continue + suffix = stat_id[len(prefix) :] + if not suffix.isdigit(): + continue + name = stat.get("name", "") + # Use the last part after splitting by space (e.g. "solaredge 1.1.1" -> "1.1.1"). + short_name = name.rsplit(" ", 1)[-1] if name else "" + if not short_name: + continue + if short_name in name_to_legacy: + del name_to_legacy[short_name] + ambiguous_names.add(short_name) + elif short_name not in ambiguous_names: + name_to_legacy[short_name] = stat_id + + self._legacy_id_map_initialized = True + + if not name_to_legacy and not ambiguous_names: + return + + # Map each serial to its old statistic ID via the equipment name. + for serial, data in equipment.items(): + name = data.get("name", "") + short_name = name.rsplit(" ", 1)[-1] if name else "" + if not short_name: + continue + if short_name in ambiguous_names: + LOGGER.warning( + "Skipping legacy statistics migration for %s %s: multiple " + "numeric statistics share the name %s, so the match is " + "ambiguous", + self.site_id, + name, + short_name, + ) + continue + if short_name in name_to_legacy: + self._serial_to_legacy_id[serial] = name_to_legacy[short_name] + + if self._serial_to_legacy_id: + LOGGER.debug( + "Mapped %s legacy SolarEdge statistics to serials for site %s", + len(self._serial_to_legacy_id), + self.site_id, + ) + + def get_statistic_id(self, equipment_id: int | str) -> str: + """Return the statistic ID for this equipment_id. + + If a legacy numeric ID exists for this serial, reuse it for backward + compatibility. Otherwise, create a new serial-based ID. + """ + if isinstance(equipment_id, str) and equipment_id in self._serial_to_legacy_id: + return self._serial_to_legacy_id[equipment_id] + if isinstance(equipment_id, str): + equipment_id = slugify(equipment_id) return f"{DOMAIN}:{self.site_id}_{equipment_id}" async def _async_get_last_sums( - self, equipment_ids: Iterable[int], start_time: datetime + self, equipment_ids: Iterable[int | str], start_time: datetime ) -> dict[str, float]: """Get the last sum from the recorder before start_time for each statistic.""" start = start_time - timedelta(hours=1) diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json index 276ddaf0c821..ab668d398096 100644 --- a/homeassistant/components/solaredge/manifest.json +++ b/homeassistant/components/solaredge/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["aiosolaredge", "solaredge_web"], - "requirements": ["aiosolaredge==1.0.2", "solaredge-web==0.0.1"] + "requirements": ["aiosolaredge==1.0.2", "solaredge-web==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8f6a93aba693..0aa3a5c53c12 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3068,7 +3068,7 @@ soco==0.31.1 solaredge-local==0.2.3 # homeassistant.components.solaredge -solaredge-web==0.0.1 +solaredge-web==0.3.0 # homeassistant.components.solarlog solarlog_cli==0.7.1 diff --git a/tests/components/solaredge/conftest.py b/tests/components/solaredge/conftest.py index 21cd8153d51c..f9e795fa80aa 100644 --- a/tests/components/solaredge/conftest.py +++ b/tests/components/solaredge/conftest.py @@ -1,12 +1,15 @@ """Common fixtures for the SolarEdge tests.""" from collections.abc import Generator +from datetime import datetime from unittest.mock import AsyncMock, Mock, patch import pytest +from solaredge_web import EnergyData from homeassistant.components.solaredge.const import CONF_SITE_ID, DOMAIN from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, load_json_object_fixture @@ -102,7 +105,13 @@ def mock_solaredge_web_api_fixture() -> Generator[AsyncMock]: api = mock_web_api_flow.return_value mock_web_api_coord.return_value = api api.async_get_equipment.return_value = { - 1001: {"displayName": "1.1"}, - 1002: {"displayName": "1.2"}, + "7A012345-CA": {"name": "Optimizer 1.1"}, + "7A012346-CA": {"name": "Optimizer 1.2"}, } + api.async_get_energy_data.return_value = [ + EnergyData( + start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 0)), + values={"7A012345-CA": 10.0, "7A012346-CA": 20.0}, + ), + ] yield api diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 367d86de3fb5..f61501348c92 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -10,7 +10,15 @@ import pytest from solaredge_web import EnergyData from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.statistics import statistics_during_period +from homeassistant.components.recorder.models import ( + StatisticMeanType, + StatisticMetaData, +) +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + async_list_statistic_ids, + statistics_during_period, +) from homeassistant.components.solaredge.const import ( CONF_SITE_ID, DATA_MODULES_COORDINATOR, @@ -29,6 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import EnergyConverter from . import setup_integration from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME @@ -169,41 +178,17 @@ def mock_solar_edge_web() -> AsyncMock: ) as mock_api: api = mock_api.return_value api.async_get_equipment.return_value = { - 1001: {"displayName": "1.1"}, - 1002: {"displayName": "1.2"}, + "7A012345-CA": {"name": "Optimizer 1.1"}, + "7A012346-CA": {"name": "Optimizer 1.2"}, } api.async_get_energy_data.return_value = [ EnergyData( start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 0)), - values={1001: 10.0, 1002: 20.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 15)), - values={1001: 11.0, 1002: 21.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 30)), - values={1001: 12.0, 1002: 22.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 10, 45)), - values={1001: 13.0, 1002: 23.0}, + values={"7A012345-CA": 10.0, "7A012346-CA": 20.0}, ), EnergyData( start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 0)), - values={1001: 14.0, 1002: 24.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 15)), - values={1001: 15.0, 1002: 25.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 30)), - values={1001: 16.0, 1002: 26.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 45)), - values={1001: 17.0, 1002: 27.0}, + values={"7A012345-CA": 14.0, "7A012346-CA": 24.0}, ), ] yield api @@ -229,19 +214,19 @@ async def test_modules_coordinator_first_run( hass, dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), None, - {f"{DOMAIN}:{SITE_ID}_1001", f"{DOMAIN}:{SITE_ID}_1002"}, + {f"{DOMAIN}:{SITE_ID}_7a012345_ca", f"{DOMAIN}:{SITE_ID}_7a012346_ca"}, "hour", None, {"state", "sum"}, ) assert stats == { - f"{DOMAIN}:{SITE_ID}_1001": [ - {"start": 1735783200.0, "end": 1735786800.0, "state": 11.5, "sum": 11.5}, - {"start": 1735786800.0, "end": 1735790400.0, "state": 15.5, "sum": 27.0}, + f"{DOMAIN}:{SITE_ID}_7a012345_ca": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 10.0, "sum": 10.0}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 14.0, "sum": 24.0}, ], - f"{DOMAIN}:{SITE_ID}_1002": [ - {"start": 1735783200.0, "end": 1735786800.0, "state": 21.5, "sum": 21.5}, - {"start": 1735786800.0, "end": 1735790400.0, "state": 25.5, "sum": 47.0}, + f"{DOMAIN}:{SITE_ID}_7a012346_ca": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 20.0, "sum": 20.0}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 24.0, "sum": 44.0}, ], } @@ -254,7 +239,7 @@ async def test_modules_coordinator_subsequent_run( ) -> None: """Test the coordinator correctly updates statistics on subsequent runs.""" mock_solar_edge_web.async_get_equipment.return_value = { - 1001: {"displayName": "1.1"}, + "7A012345-CA": {"name": "Optimizer 1.1"}, } entry = MockConfigEntry( domain=DOMAIN, @@ -269,36 +254,12 @@ async def test_modules_coordinator_subsequent_run( # Updated values, different from the first run EnergyData( start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 0)), - values={1001: 24.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 15)), - values={1001: 25.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 30)), - values={1001: 26.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 11, 45)), - values={1001: 27.0}, + values={"7A012345-CA": 24.0}, ), # New values for the next hour EnergyData( start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 0)), - values={1001: 28.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 15)), - values={1001: 29.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 30)), - values={1001: 30.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 1, 1, 12, 45)), - values={1001: 31.0}, + values={"7A012345-CA": 28.0}, ), ] @@ -313,16 +274,16 @@ async def test_modules_coordinator_subsequent_run( hass, dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), None, - {f"{DOMAIN}:{SITE_ID}_1001"}, + {f"{DOMAIN}:{SITE_ID}_7a012345_ca"}, "hour", None, {"state", "sum"}, ) assert stats == { - f"{DOMAIN}:{SITE_ID}_1001": [ - {"start": 1735783200.0, "end": 1735786800.0, "state": 11.5, "sum": 11.5}, - {"start": 1735786800.0, "end": 1735790400.0, "state": 25.5, "sum": 37.0}, - {"start": 1735790400.0, "end": 1735794000.0, "state": 29.5, "sum": 66.5}, + f"{DOMAIN}:{SITE_ID}_7a012345_ca": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 10.0, "sum": 10.0}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 24.0, "sum": 34.0}, + {"start": 1735790400.0, "end": 1735794000.0, "state": 28.0, "sum": 62.0}, ] } @@ -335,7 +296,7 @@ async def test_modules_coordinator_subsequent_run_with_gap( ) -> None: """Test the coordinator updates statistics with a gap in data.""" mock_solar_edge_web.async_get_equipment.return_value = { - 1001: {"displayName": "1.1"}, + "7A012345-CA": {"name": "Optimizer 1.1"}, } entry = MockConfigEntry( domain=DOMAIN, @@ -350,19 +311,7 @@ async def test_modules_coordinator_subsequent_run_with_gap( # New values a month later, simulating a gap in data EnergyData( start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 0)), - values={1001: 24.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 15)), - values={1001: 25.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 30)), - values={1001: 26.0}, - ), - EnergyData( - start_time=dt_util.as_utc(datetime(2025, 2, 1, 11, 45)), - values={1001: 27.0}, + values={"7A012345-CA": 24.0}, ), ] @@ -376,16 +325,16 @@ async def test_modules_coordinator_subsequent_run_with_gap( hass, dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), None, - {f"{DOMAIN}:{SITE_ID}_1001"}, + {f"{DOMAIN}:{SITE_ID}_7a012345_ca"}, "hour", None, {"state", "sum"}, ) assert stats == { - f"{DOMAIN}:{SITE_ID}_1001": [ - {"start": 1735783200.0, "end": 1735786800.0, "state": 11.5, "sum": 11.5}, - {"start": 1735786800.0, "end": 1735790400.0, "state": 15.5, "sum": 27.0}, - {"start": 1738465200.0, "end": 1738468800.0, "state": 25.5, "sum": 52.5}, + f"{DOMAIN}:{SITE_ID}_7a012345_ca": [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 10.0, "sum": 10.0}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 14.0, "sum": 24.0}, + {"start": 1738465200.0, "end": 1738468800.0, "state": 24.0, "sum": 48.0}, ] } @@ -415,7 +364,7 @@ async def test_modules_coordinator_no_energy_data( hass, dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), None, - {f"{DOMAIN}:{SITE_ID}_1001", f"{DOMAIN}:{SITE_ID}_1002"}, + {f"{DOMAIN}:{SITE_ID}_7a012345_ca", f"{DOMAIN}:{SITE_ID}_7a012346_ca"}, "hour", None, {"state", "sum"}, @@ -435,3 +384,117 @@ async def test_modules_coordinator_api_failure( await setup_integration(hass, mock_config_entry_web_login) assert mock_config_entry_web_login.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("recorder_mock") +async def test_legacy_statistic_id_reused( + hass: HomeAssistant, + mock_solar_edge_web: AsyncMock, +) -> None: + """Test that legacy numeric statistic IDs are reused via name matching.""" + # Add a legacy numeric statistic with name "1.1" (matching equipment name). + legacy_statistic_id = f"{DOMAIN}:{SITE_ID}_231397259" + legacy_metadata = StatisticMetaData( + mean_type=StatisticMeanType.ARITHMETIC, + has_sum=True, + name="SolarEdge 1.1", + source=DOMAIN, + statistic_id=legacy_statistic_id, + unit_class=EnergyConverter.UNIT_CLASS, + unit_of_measurement="Wh", + ) + async_add_external_statistics(hass, legacy_metadata, []) + await async_wait_recording_done(hass) + + # Equipment with matching name "1.1". + mock_solar_edge_web.async_get_equipment.return_value = { + "7A012345-CA": {"name": "Optimizer 1.1"}, + } + + entry = MockConfigEntry( + domain=DOMAIN, + title="SolarEdge", + data={CONF_SITE_ID: SITE_ID, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + # The legacy statistic ID should be reused (not a new serial-based one). + coordinator: SolarEdgeModulesCoordinator = entry.runtime_data[ + DATA_MODULES_COORDINATOR + ] + assert coordinator.get_statistic_id("7A012345-CA") == legacy_statistic_id + + # Data should be inserted under the legacy ID. + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.as_utc(datetime(1970, 1, 1, 0, 0)), + None, + {legacy_statistic_id}, + "hour", + None, + {"state", "sum"}, + ) + assert stats == { + legacy_statistic_id: [ + {"start": 1735783200.0, "end": 1735786800.0, "state": 10.0, "sum": 10.0}, + {"start": 1735786800.0, "end": 1735790400.0, "state": 14.0, "sum": 24.0}, + ] + } + # No new serial-based ID should have been created. + all_stats = await async_list_statistic_ids(hass) + stat_ids = {s["statistic_id"] for s in all_stats} + assert f"{DOMAIN}:{SITE_ID}_7a012345_ca" not in stat_ids + + +@pytest.mark.usefixtures("recorder_mock") +async def test_legacy_statistic_id_ambiguous( + hass: HomeAssistant, + mock_solar_edge_web: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that ambiguous legacy statistic names are not reused.""" + # Two legacy numeric statistics with the same name, e.g. after a module + # replacement created a new series under a new numeric ID. + for legacy_id in ( + f"{DOMAIN}:{SITE_ID}_231397259", + f"{DOMAIN}:{SITE_ID}_231397260", + ): + legacy_metadata = StatisticMetaData( + mean_type=StatisticMeanType.ARITHMETIC, + has_sum=True, + name="SolarEdge 1.1", + source=DOMAIN, + statistic_id=legacy_id, + unit_class=EnergyConverter.UNIT_CLASS, + unit_of_measurement="Wh", + ) + async_add_external_statistics(hass, legacy_metadata, []) + await async_wait_recording_done(hass) + + # Equipment with matching name "1.1". + mock_solar_edge_web.async_get_equipment.return_value = { + "7A012345-CA": {"name": "Optimizer 1.1"}, + } + + entry = MockConfigEntry( + domain=DOMAIN, + title="SolarEdge", + data={CONF_SITE_ID: SITE_ID, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + # The match is ambiguous, so a new serial-based ID is used. + coordinator: SolarEdgeModulesCoordinator = entry.runtime_data[ + DATA_MODULES_COORDINATOR + ] + assert ( + coordinator.get_statistic_id("7A012345-CA") == f"{DOMAIN}:{SITE_ID}_7a012345_ca" + ) + assert "ambiguous" in caplog.text