Improve test coverage for the SolarEdge integration (#169178)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Mika
2026-05-13 19:57:01 +02:00
committed by GitHub
co-authored by Claude
parent 4e01805270
commit e8a36f7128
12 changed files with 2287 additions and 259 deletions
+13
View File
@@ -1 +1,14 @@
"""Tests for the SolarEdge component."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Set up the SolarEdge integration in Home Assistant."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
+49 -1
View File
@@ -5,6 +5,11 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from homeassistant.components.solaredge.const import CONF_SITE_ID, DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry, load_json_object_fixture
SITE_ID = "1a2b3c4d5e6f7g8h"
API_KEY = "a1b2c3d4e5f6g7h8"
USERNAME = "test-username"
@@ -21,11 +26,54 @@ def mock_setup_entry() -> Generator[AsyncMock]:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a MockConfigEntry with API key auth."""
return MockConfigEntry(
domain=DOMAIN,
title="SolarEdge",
unique_id=SITE_ID,
data={CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY},
)
@pytest.fixture
def mock_config_entry_web_login() -> MockConfigEntry:
"""Return a MockConfigEntry with web login auth."""
return MockConfigEntry(
domain=DOMAIN,
title="SolarEdge",
unique_id=SITE_ID,
data={
CONF_SITE_ID: SITE_ID,
CONF_USERNAME: USERNAME,
CONF_PASSWORD: PASSWORD,
},
)
@pytest.fixture(name="solaredge_api")
def mock_solaredge_api_fixture() -> Generator[Mock]:
"""Mock a successful SolarEdge Monitoring API."""
api = Mock()
api.get_details = AsyncMock(return_value={"details": {"status": "active"}})
api.get_details = AsyncMock(
return_value=load_json_object_fixture("details.json", DOMAIN)
)
api.get_overview = AsyncMock(
return_value=load_json_object_fixture("overview.json", DOMAIN)
)
api.get_inventory = AsyncMock(
return_value=load_json_object_fixture("inventory.json", DOMAIN)
)
api.get_current_power_flow = AsyncMock(
return_value=load_json_object_fixture("power_flow.json", DOMAIN)
)
api.get_energy_details = AsyncMock(
return_value=load_json_object_fixture("energy_details.json", DOMAIN)
)
api.get_storage_data = AsyncMock(
return_value=load_json_object_fixture("storage_data.json", DOMAIN)
)
with (
patch(
"homeassistant.components.solaredge.config_flow.aiosolaredge.SolarEdge",
@@ -0,0 +1,19 @@
{
"details": {
"id": 1,
"name": "Test Site",
"accountId": 1,
"status": "Active",
"peakPower": 5.5,
"lastUpdateTime": "2025-01-01",
"installationDate": "2024-01-01",
"ptoDate": null,
"notes": "",
"type": "Optimizers & Inverters",
"primaryModule": {
"manufacturerName": "TestManufacturer",
"modelName": "TestModel",
"maximumPower": 350.0
}
}
}
@@ -0,0 +1,28 @@
{
"energyDetails": {
"timeUnit": "DAY",
"unit": "Wh",
"meters": [
{
"type": "Production",
"values": [{ "date": "2025-01-01 00:00:00", "value": 12345.0 }]
},
{
"type": "Consumption",
"values": [{ "date": "2025-01-01 00:00:00", "value": 9876.0 }]
},
{
"type": "SelfConsumption",
"values": [{ "date": "2025-01-01 00:00:00", "value": 5432.0 }]
},
{
"type": "FeedIn",
"values": [{ "date": "2025-01-01 00:00:00", "value": 6913.0 }]
},
{
"type": "Purchased",
"values": [{ "date": "2025-01-01 00:00:00", "value": 4444.0 }]
}
]
}
}
@@ -0,0 +1,39 @@
{
"Inventory": {
"meters": [
{ "name": "Production meter", "manufacturer": "Acme", "model": "M1" },
{ "name": "Consumption meter", "manufacturer": "Acme", "model": "M1" }
],
"sensors": [],
"gateways": [{ "name": "Gateway1", "serialNumber": "G-001" }],
"batteries": [
{
"name": "Battery1",
"manufacturer": "Acme",
"model": "B1",
"SN": "BAT001",
"nameplateCapacity": 9700.0
}
],
"inverters": [
{
"name": "Inverter1",
"manufacturer": "Acme",
"model": "I1",
"serialNumber": "INV-001"
},
{
"name": "Inverter2",
"manufacturer": "Acme",
"model": "I1",
"serialNumber": "INV-002"
},
{
"name": "Inverter3",
"manufacturer": "Acme",
"model": "I1",
"serialNumber": "INV-003"
}
]
}
}
@@ -0,0 +1,11 @@
{
"overview": {
"lastUpdateTime": "2025-01-01 12:00:00",
"lifeTimeData": { "energy": 100000.0, "revenue": 10.0 },
"lastYearData": { "energy": 50000.0 },
"lastMonthData": { "energy": 10000.0 },
"lastDayData": { "energy": 250.0 },
"currentPower": { "power": 1234.5 },
"measuredBy": "INVERTER"
}
}
@@ -0,0 +1,20 @@
{
"siteCurrentPowerFlow": {
"updateRefreshRate": 3,
"unit": "W",
"connections": [
{ "from": "GRID", "to": "Load" },
{ "from": "PV", "to": "Load" },
{ "from": "PV", "to": "STORAGE" }
],
"GRID": { "status": "Active", "currentPower": 250.0 },
"LOAD": { "status": "Active", "currentPower": 1500.0 },
"PV": { "status": "Active", "currentPower": 1500.0 },
"STORAGE": {
"status": "Charging",
"currentPower": 250.0,
"chargeLevel": 75,
"critical": false
}
}
}
@@ -0,0 +1,25 @@
{
"storageData": {
"batteries": [
{
"serialNumber": "BAT001",
"telemetries": [
{
"timeStamp": "2025-01-01 00:00:00",
"lifeTimeEnergyCharged": 1000.0,
"lifeTimeEnergyDischarged": 500.0,
"batteryPercentageState": 50.0,
"power": 100.0
},
{
"timeStamp": "2025-01-01 12:00:00",
"lifeTimeEnergyCharged": 1500.0,
"lifeTimeEnergyDischarged": 800.0,
"batteryPercentageState": 75.0,
"power": 200.0
}
]
}
]
}
}
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import asyncio
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
import pytest
from solaredge_web import EnergyData
@@ -18,6 +19,7 @@ from homeassistant.components.solaredge.const import (
OVERVIEW_UPDATE_DELAY,
)
from homeassistant.components.solaredge.coordinator import SolarEdgeModulesCoordinator
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
CONF_API_KEY,
CONF_NAME,
@@ -28,6 +30,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import setup_integration
from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -417,3 +420,17 @@ async def test_modules_coordinator_no_energy_data(
{"state", "sum"},
)
assert not stats
async def test_modules_coordinator_api_failure(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry_web_login: MockConfigEntry,
solaredge_web_api: AsyncMock,
) -> None:
"""Test the modules coordinator surfaces API failures via the config entry state."""
solaredge_web_api.async_get_equipment.side_effect = ClientError("boom")
await setup_integration(hass, mock_config_entry_web_login)
assert mock_config_entry_web_login.state is ConfigEntryState.SETUP_RETRY
+28
View File
@@ -3,6 +3,7 @@
from unittest.mock import AsyncMock, Mock, patch
from aiohttp import ClientError
import pytest
from homeassistant.components.recorder import Recorder
from homeassistant.components.solaredge.const import CONF_SITE_ID, DOMAIN
@@ -10,6 +11,7 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from . import setup_integration
from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME
from tests.common import MockConfigEntry
@@ -156,3 +158,29 @@ async def test_web_login_config_not_ready(
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
("get_details_response", "expected_state"),
[
# Missing 'details' key → ConfigEntryNotReady → SETUP_RETRY
({}, ConfigEntryState.SETUP_RETRY),
# Site status is not 'active' → setup returns False → SETUP_ERROR
({"details": {"status": "Disabled"}}, ConfigEntryState.SETUP_ERROR),
],
ids=["missing_details_key", "site_not_active"],
)
async def test_setup_api_key_failure(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
get_details_response: dict,
expected_state: ConfigEntryState,
) -> None:
"""Test the API-key setup failure paths in async_setup_entry."""
solaredge_api.get_details.return_value = get_details_response
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is expected_state
+456 -258
View File
@@ -1,100 +1,22 @@
"""Tests for the SolarEdge sensors."""
"""Tests for the SolarEdge sensor platform."""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, Mock, patch
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.recorder import Recorder
from homeassistant.components.solaredge.const import (
CONF_SITE_ID,
DEFAULT_NAME,
DOMAIN,
INVENTORY_UPDATE_DELAY,
)
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.components.solaredge.const import DOMAIN, INVENTORY_UPDATE_DELAY
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import API_KEY, SITE_ID
from . import setup_integration
from .conftest import SITE_ID
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture(autouse=True)
def enable_all_entities(entity_registry_enabled_by_default: None) -> None:
"""Make sure all entities are enabled."""
@pytest.fixture
def mock_solaredge_api() -> AsyncMock:
"""Return a mocked SolarEdge API with common defaults."""
api = AsyncMock()
api.get_details = AsyncMock(return_value={"details": {"status": "active"}})
api.get_overview = AsyncMock(
return_value={
"overview": {
"lifeTimeData": {"energy": 100000},
"lastYearData": {"energy": 50000},
"lastMonthData": {"energy": 10000},
"lastDayData": {"energy": 0.0},
"currentPower": {"power": 0.0},
}
}
)
api.get_inventory = AsyncMock(
return_value={"Inventory": {"batteries": [{"SN": "BAT001"}]}}
)
api.get_current_power_flow = AsyncMock(
return_value={
"siteCurrentPowerFlow": {
"unit": "W",
"connections": [],
}
}
)
api.get_energy_details = AsyncMock(
return_value={"energyDetails": {"unit": "Wh", "meters": []}}
)
api.get_storage_data = AsyncMock(return_value=STORAGE_DATA_SINGLE_BATTERY)
return api
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a default mocked config entry for storage tests."""
return MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
data={CONF_NAME: DEFAULT_NAME, CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY},
)
STORAGE_DATA_SINGLE_BATTERY = {
"storageData": {
"batteries": [
{
"serialNumber": "BAT001",
"telemetries": [
{
"timeStamp": "2025-01-01 00:00:00",
"lifeTimeEnergyCharged": 1000.0,
"lifeTimeEnergyDischarged": 500.0,
"batteryPercentageState": 50.0,
"power": 100.0,
},
{
"timeStamp": "2025-01-01 12:00:00",
"lifeTimeEnergyCharged": 1500.0,
"lifeTimeEnergyDischarged": 800.0,
"batteryPercentageState": 75.0,
"power": 200.0,
},
],
}
]
}
}
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
STORAGE_DATA_MULTI_BATTERY = {
"storageData": {
@@ -142,22 +64,285 @@ STORAGE_DATA_MULTI_BATTERY = {
}
@patch("homeassistant.components.solaredge.SolarEdge")
async def test_storage_data_service(
mock_solaredge: MagicMock,
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_solaredge_api: AsyncMock,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test all sensor entities are created with the correct state and registry entry."""
with patch("homeassistant.components.solaredge.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_overview_sensors_unavailable_on_api_error(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test overview-based sensors are unavailable when overview API fails."""
solaredge_api.get_overview.side_effect = ClientError()
await setup_integration(hass, mock_config_entry)
for sensor_id in (
"sensor.solaredge_lifetime_energy",
"sensor.solaredge_energy_this_year",
"sensor.solaredge_energy_this_month",
"sensor.solaredge_energy_today",
"sensor.solaredge_current_power",
):
state = hass.states.get(sensor_id)
assert state is not None, sensor_id
assert state.state == STATE_UNAVAILABLE, sensor_id
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_level_unknown_when_storage_missing(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test storage_level returns None (unknown) when site has no storage in flow data."""
power_flow = solaredge_api.get_current_power_flow.return_value
power_flow["siteCurrentPowerFlow"].pop("STORAGE")
# Drop STORAGE from connections too so the data service does not reference it.
power_flow["siteCurrentPowerFlow"]["connections"] = [
{"from": "GRID", "to": "Load"},
{"from": "PV", "to": "Load"},
]
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.solaredge_storage_level")
assert state is not None
assert state.state == STATE_UNKNOWN
async def test_no_sensors_without_api_key(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry_web_login: MockConfigEntry,
solaredge_web_api: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test no sensors are created when only web login auth is configured."""
await setup_integration(hass, mock_config_entry_web_login)
entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry_web_login.entry_id
)
assert entries == []
@pytest.mark.parametrize(
("api_method", "sensor_id"),
[
("get_overview", "sensor.solaredge_lifetime_energy"),
("get_inventory", "sensor.solaredge_inverters"),
("get_current_power_flow", "sensor.solaredge_grid_power"),
("get_energy_details", "sensor.solaredge_produced_energy"),
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_unavailable_on_data_service_keyerror(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
api_method: str,
sensor_id: str,
) -> None:
"""Test sensors become unavailable when a data service refresh raises UpdateFailed."""
getattr(solaredge_api, api_method).return_value = {}
await setup_integration(hass, mock_config_entry)
state = hass.states.get(sensor_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_details_sensor_unavailable_on_data_service_keyerror(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test the details sensor becomes unavailable when its refresh fails.
`get_details` is also called during setup validation, so the first call
must succeed; subsequent calls (the data service refresh) return data
without the 'details' key to trigger UpdateFailed.
"""
solaredge_api.get_details.side_effect = [
{"details": {"status": "Active"}},
{},
]
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.solaredge_site_details")
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_energy_details_sensor_unknown_when_no_meters(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test energy detail sensors stay unknown when the API reports no meters."""
solaredge_api.get_energy_details.return_value = {"energyDetails": {"unit": "Wh"}}
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.solaredge_produced_energy")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_energy_details_filters_meters(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test energy details data service skips meters without type/values and unsupported types."""
solaredge_api.get_energy_details.return_value = {
"energyDetails": {
"unit": "Wh",
"meters": [
{"type": "Production"}, # missing values, skipped
{"values": [{"date": "2025-01-01", "value": 1.0}]}, # missing type
{
"type": "SomethingElse", # unsupported type, skipped
"values": [{"date": "2025-01-01", "value": 2.0}],
},
{
"type": "Production",
"values": [{"date": "2025-01-01", "value": 100.0}],
},
],
}
}
await setup_integration(hass, mock_config_entry)
produced = hass.states.get("sensor.solaredge_produced_energy")
assert produced is not None
assert produced.state == "100.0"
assert produced.attributes["date"] == "2025-01-01"
consumed = hass.states.get("sensor.solaredge_consumed_energy")
assert consumed is not None
assert consumed.state == STATE_UNKNOWN
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_power_flow_sensor_unknown_when_no_connections(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test power flow sensors stay unknown when the API reports no connections."""
solaredge_api.get_current_power_flow.return_value = {
"siteCurrentPowerFlow": {"unit": "W"}
}
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.solaredge_grid_power")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_power_flow_grid_export_storage_discharge(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test power flow sign flipping for grid export and reports for storage discharge."""
solaredge_api.get_current_power_flow.return_value = {
"siteCurrentPowerFlow": {
"unit": "W",
"connections": [
{"from": "PV", "to": "GRID"},
{"from": "STORAGE", "to": "Load"},
],
"GRID": {"status": "Active", "currentPower": 100.0},
"LOAD": {"status": "Active", "currentPower": 500.0},
"PV": {"status": "Active", "currentPower": 600.0},
"STORAGE": {
"status": "Discharging",
"currentPower": 400.0,
"chargeLevel": 60,
},
}
}
await setup_integration(hass, mock_config_entry)
grid = hass.states.get("sensor.solaredge_grid_power")
assert grid is not None
assert grid.state == "-100.0"
assert grid.attributes["flow"] == "export"
storage = hass.states.get("sensor.solaredge_storage_power")
assert storage is not None
assert storage.state == "400.0"
assert storage.attributes["flow"] == "discharge"
assert storage.attributes["soc"] == 60
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_power_flow_zero_current_power_keeps_zero(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test power flow leaves zero values untouched (no -0 in the state)."""
solaredge_api.get_current_power_flow.return_value = {
"siteCurrentPowerFlow": {
"unit": "W",
"connections": [{"from": "PV", "to": "GRID"}],
"GRID": {"status": "Idle", "currentPower": 0},
"STORAGE": {"status": "Idle", "currentPower": 0, "chargeLevel": 50},
}
}
await setup_integration(hass, mock_config_entry)
assert hass.states.get("sensor.solaredge_grid_power").state == "0"
assert hass.states.get("sensor.solaredge_storage_power").state == "0"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_data_service(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage data service fetches battery charge/discharge energy."""
mock_solaredge.return_value = mock_solaredge_api
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await setup_integration(hass, mock_config_entry)
# Aggregate sensors
charge_entry = entity_registry.async_get_entity_id(
@@ -177,7 +362,7 @@ async def test_storage_data_service(
assert state is not None
assert float(state.state) == 300.0 # 800 - 500
# Per-battery entities
# Per-battery entities for BAT001
bat_charge = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_charge_energy"
)
@@ -212,28 +397,20 @@ async def test_storage_data_service(
assert float(state.state) == 200.0
@patch("homeassistant.components.solaredge.SolarEdge")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_data_service_multi_battery(
mock_solaredge: MagicMock,
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_solaredge_api: AsyncMock,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage data service aggregates data across multiple batteries."""
mock_solaredge_api.get_inventory = AsyncMock(
return_value={"Inventory": {"batteries": [{"SN": "BAT001"}, {"SN": "BAT002"}]}}
)
mock_solaredge_api.get_storage_data = AsyncMock(
return_value=STORAGE_DATA_MULTI_BATTERY
)
mock_solaredge.return_value = mock_solaredge_api
inventory = solaredge_api.get_inventory.return_value
inventory["Inventory"]["batteries"] = [{"SN": "BAT001"}, {"SN": "BAT002"}]
solaredge_api.get_storage_data.return_value = STORAGE_DATA_MULTI_BATTERY
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await setup_integration(hass, mock_config_entry)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
@@ -246,24 +423,15 @@ async def test_storage_data_service_multi_battery(
# BAT001: charge=500 (1500-1000), discharge=300 (800-500)
# BAT002: charge=700 (2700-2000), discharge=400 (1400-1000)
state = hass.states.get(charge_entry)
assert state is not None
assert float(state.state) == 1200.0 # 500 + 700
assert float(hass.states.get(charge_entry).state) == 1200.0
assert float(hass.states.get(discharge_entry).state) == 700.0
state = hass.states.get(discharge_entry)
assert state is not None
assert float(state.state) == 700.0 # 300 + 400
# Per-battery entities for BAT001
bat1_soc = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_state_of_charge"
)
assert bat1_soc is not None
state = hass.states.get(bat1_soc)
assert state is not None
assert float(state.state) == 75.0
assert float(hass.states.get(bat1_soc).state) == 75.0
# Per-battery entities for BAT002
bat2_charge = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_BAT002_battery_charge_energy"
)
@@ -272,37 +440,23 @@ async def test_storage_data_service_multi_battery(
)
assert bat2_charge is not None
assert bat2_soc is not None
state = hass.states.get(bat2_charge)
assert state is not None
assert float(state.state) == 700.0
state = hass.states.get(bat2_soc)
assert state is not None
assert float(state.state) == 80.0
assert float(hass.states.get(bat2_charge).state) == 700.0
assert float(hass.states.get(bat2_soc).state) == 80.0
@patch("homeassistant.components.solaredge.SolarEdge")
async def test_storage_data_service_no_batteries(
mock_solaredge: MagicMock,
async def test_storage_service_not_created_when_inventory_has_no_batteries(
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_solaredge_api: AsyncMock,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage service is not created when no batteries in inventory."""
mock_solaredge_api.get_inventory = AsyncMock(
return_value={"Inventory": {"batteries": []}}
)
mock_solaredge.return_value = mock_solaredge_api
inventory = solaredge_api.get_inventory.return_value
inventory["Inventory"]["batteries"] = []
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await setup_integration(hass, mock_config_entry)
# Sensors should not exist when inventory reports no batteries
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
@@ -313,23 +467,18 @@ async def test_storage_data_service_no_batteries(
assert discharge_entry is None
@patch("homeassistant.components.solaredge.SolarEdge")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_data_service_api_error(
mock_solaredge: MagicMock,
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_solaredge_api: AsyncMock,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage data service handles API errors gracefully."""
mock_solaredge_api.get_storage_data = AsyncMock(side_effect=Exception("API error"))
mock_solaredge.return_value = mock_solaredge_api
"""Test storage sensors are unavailable when the storage API errors out."""
solaredge_api.get_storage_data.side_effect = Exception("API error")
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await setup_integration(hass, mock_config_entry)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
@@ -340,34 +489,28 @@ async def test_storage_data_service_api_error(
assert charge_entry is not None
assert discharge_entry is not None
# Sensors should be unavailable when the API returns an error
state = hass.states.get(charge_entry)
assert state is not None
assert state.state == "unavailable"
state = hass.states.get(discharge_entry)
assert state is not None
assert state.state == "unavailable"
assert hass.states.get(charge_entry).state == STATE_UNAVAILABLE
assert hass.states.get(discharge_entry).state == STATE_UNAVAILABLE
@patch("homeassistant.components.solaredge.SolarEdge")
@pytest.mark.parametrize(
"bad_response",
[{"unexpected": {}}, {"storageData": {"otherField": "value"}}],
ids=["missing_storageData", "missing_batteries"],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_data_missing_keys_in_response(
mock_solaredge: MagicMock,
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_solaredge_api: AsyncMock,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
bad_response: dict,
) -> None:
"""Test storage service raises UpdateFailed when response is missing required keys."""
# API returns a response but without the storageData key
mock_solaredge_api.get_storage_data = AsyncMock(return_value={"unexpected": {}})
mock_solaredge.return_value = mock_solaredge_api
"""Test storage sensors are unavailable when the response is missing required keys."""
solaredge_api.get_storage_data.return_value = bad_response
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await setup_integration(hass, mock_config_entry)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
@@ -377,126 +520,181 @@ async def test_storage_data_missing_keys_in_response(
)
assert charge_entry is not None
assert discharge_entry is not None
# Sensors should be unavailable due to UpdateFailed from missing key
state = hass.states.get(charge_entry)
assert state is not None
assert state.state == "unavailable"
state = hass.states.get(discharge_entry)
assert state is not None
assert state.state == "unavailable"
assert hass.states.get(charge_entry).state == STATE_UNAVAILABLE
assert hass.states.get(discharge_entry).state == STATE_UNAVAILABLE
@patch("homeassistant.components.solaredge.SolarEdge")
async def test_storage_data_missing_batteries_key(
mock_solaredge: MagicMock,
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_solaredge_api: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage service raises UpdateFailed when batteries key is missing."""
# API returns storageData but without batteries key
mock_solaredge_api.get_storage_data = AsyncMock(
return_value={"storageData": {"otherField": "value"}}
)
mock_solaredge.return_value = mock_solaredge_api
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
assert charge_entry is not None
state = hass.states.get(charge_entry)
assert state is not None
assert state.state == "unavailable"
@patch("homeassistant.components.solaredge.SolarEdge")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_service_deferred_after_inventory_failure(
mock_solaredge: MagicMock,
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_solaredge_api: AsyncMock,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage service is created after inventory recovers from failure."""
# Initial inventory fetch fails
mock_solaredge_api.get_inventory = AsyncMock(side_effect=KeyError("Inventory"))
mock_solaredge.return_value = mock_solaredge_api
"""Test storage service is created after inventory recovers from a failure."""
valid_inventory = solaredge_api.get_inventory.return_value
solaredge_api.get_inventory.side_effect = KeyError("Inventory")
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await setup_integration(hass, mock_config_entry)
# Storage sensors should not exist yet
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
assert charge_entry is None
# Now inventory recovers and reports batteries
mock_solaredge_api.get_inventory = AsyncMock(
return_value={"Inventory": {"batteries": [{"SN": "BAT001"}]}}
)
mock_solaredge_api.get_storage_data = AsyncMock(
return_value=STORAGE_DATA_SINGLE_BATTERY
# Storage sensors are not created yet — the inventory fetch failed.
assert (
entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
is None
)
# Trigger inventory coordinator refresh
# Inventory recovers and reports a battery → storage sensors get created.
solaredge_api.get_inventory.side_effect = None
solaredge_api.get_inventory.return_value = valid_inventory
freezer.tick(INVENTORY_UPDATE_DELAY)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Storage sensors should now exist
assert (
entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
is not None
)
assert (
entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy"
)
is not None
)
@pytest.mark.parametrize(
("storage_response", "expected_charge_state"),
[
# Empty batteries list → data service returns early, aggregate stays unset.
({"storageData": {"batteries": []}}, STATE_UNKNOWN),
# Battery missing the serialNumber key → skipped in the loop, aggregate
# falls through with the initial 0.0 totals.
({"storageData": {"batteries": [{"telemetries": []}]}}, "0.0"),
# Battery with no telemetries → skipped after the serial check.
(
{
"storageData": {
"batteries": [{"serialNumber": "BAT001", "telemetries": []}]
}
},
"0.0",
),
# Battery with a single telemetry → can't compute a delta, contributes
# 0.0 to the aggregate via the len < 2 branch.
(
{
"storageData": {
"batteries": [
{
"serialNumber": "BAT001",
"telemetries": [
{
"timeStamp": "2025-01-01 00:00:00",
"lifeTimeEnergyCharged": 1000.0,
"lifeTimeEnergyDischarged": 500.0,
"batteryPercentageState": 50.0,
"power": 100.0,
}
],
}
]
}
},
"0.0",
),
],
ids=[
"empty_batteries",
"battery_without_serial",
"battery_without_telemetries",
"battery_with_single_telemetry",
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_data_service_handles_malformed_responses(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
storage_response: dict,
expected_charge_state: str,
) -> None:
"""Test storage data service tolerates batteries without serial / telemetries / single telemetry."""
solaredge_api.get_storage_data.return_value = storage_response
await setup_integration(hass, mock_config_entry)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
discharge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy"
)
assert charge_entry is not None
assert discharge_entry is not None
state = hass.states.get(charge_entry)
assert state is not None
assert state.state == expected_charge_state
@patch("homeassistant.components.solaredge.SolarEdge")
async def test_storage_service_not_created_when_inventory_has_no_batteries(
mock_solaredge: MagicMock,
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_inventory_battery_without_serial_skipped(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test inventory batteries without a serial number are skipped for per-battery sensors."""
inventory = solaredge_api.get_inventory.return_value
inventory["Inventory"]["batteries"] = [{"name": "Battery without serial"}]
await setup_integration(hass, mock_config_entry)
# Aggregate sensors are still created (battery exists in inventory)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
assert charge_entry is not None
# No per-battery sensors because the battery has no serial.
# Per-battery unique_ids follow `{site_id}_{serial}_battery_{key}`.
per_battery_entries = [
e
for e in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
if "_battery_" in e.unique_id
]
assert per_battery_entries == []
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_service_not_retried_after_recovery_with_no_batteries(
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_solaredge_api: AsyncMock,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage service is not retried when inventory succeeds with no batteries."""
# Initial inventory fails
mock_solaredge_api.get_inventory = AsyncMock(side_effect=KeyError("Inventory"))
mock_solaredge.return_value = mock_solaredge_api
"""Test storage service stays idle when inventory recovers but reports no batteries."""
solaredge_api.get_inventory.side_effect = KeyError("Inventory")
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
await setup_integration(hass, mock_config_entry)
# Inventory recovers but reports zero batteries
mock_solaredge_api.get_inventory = AsyncMock(
return_value={"Inventory": {"batteries": []}}
)
# Inventory recovers but reports zero batteries.
solaredge_api.get_inventory.side_effect = None
solaredge_api.get_inventory.return_value = {"Inventory": {"batteries": []}}
freezer.tick(INVENTORY_UPDATE_DELAY)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Storage sensors should still not exist
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)