Bump solaredge-web to 0.3.0 (#177899)

This commit is contained in:
tronikos
2026-08-04 14:33:38 +02:00
committed by Bram Kragten
parent 7e7d4ec770
commit dee4e8b5aa
5 changed files with 257 additions and 118 deletions
@@ -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)
@@ -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"]
}
+1 -1
View File
@@ -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
+11 -2
View File
@@ -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
+151 -88
View File
@@ -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