1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Add battery storage data sensors to SolarEdge integration (#161722)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: it-rec <19797875+it-rec@users.noreply.github.com>
This commit is contained in:
Mika
2026-04-25 23:00:49 +02:00
committed by GitHub
parent b474a42844
commit 759ac2eacd
6 changed files with 796 additions and 15 deletions
@@ -22,6 +22,7 @@ DETAILS_UPDATE_DELAY = timedelta(hours=12)
INVENTORY_UPDATE_DELAY = timedelta(hours=12)
POWER_FLOW_UPDATE_DELAY = timedelta(minutes=15)
ENERGY_DETAILS_DELAY = timedelta(minutes=15)
STORAGE_DATA_UPDATE_DELAY = timedelta(hours=4)
MODULE_STATISTICS_UPDATE_DELAY = timedelta(hours=12)
SCAN_INTERVAL = timedelta(minutes=15)
@@ -38,6 +38,7 @@ from .const import (
MODULE_STATISTICS_UPDATE_DELAY,
OVERVIEW_UPDATE_DELAY,
POWER_FLOW_UPDATE_DELAY,
STORAGE_DATA_UPDATE_DELAY,
)
if TYPE_CHECKING:
@@ -334,6 +335,86 @@ class SolarEdgePowerFlowDataService(SolarEdgeDataService):
LOGGER.debug("Updated SolarEdge power flow: %s, %s", self.data, self.attributes)
class SolarEdgeStorageDataService(SolarEdgeDataService):
"""Get and update the latest storage data."""
@property
def update_interval(self) -> timedelta:
"""Update interval."""
return STORAGE_DATA_UPDATE_DELAY
async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
now = dt_util.now()
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
data = await self.api.get_storage_data(
self.site_id,
start_of_day,
now,
)
storage_data = data.get("storageData")
if storage_data is None:
raise UpdateFailed("Storage data not available from API")
batteries = storage_data.get("batteries")
if batteries is None:
raise UpdateFailed("Battery data not available from API")
self.data = {}
self.attributes = {}
if not batteries:
LOGGER.debug("No batteries found in storage data")
return
# Aggregate totals across all batteries
total_charge_energy = 0.0
total_discharge_energy = 0.0
for battery in batteries:
serial = battery.get("serialNumber")
if not serial:
LOGGER.debug("Skipping battery without serialNumber")
continue
telemetries = battery.get("telemetries", [])
if not telemetries:
continue
latest = telemetries[-1]
# Per-battery current values
self.data[f"{serial}_state_of_charge"] = latest.get(
"batteryPercentageState"
)
self.data[f"{serial}_power"] = latest.get("power")
# Compute daily charge/discharge delta from lifetime counters
if len(telemetries) >= 2:
first = telemetries[0]
charge_energy = latest.get("lifeTimeEnergyCharged", 0.0) - first.get(
"lifeTimeEnergyCharged", 0.0
)
discharge_energy = latest.get(
"lifeTimeEnergyDischarged", 0.0
) - first.get("lifeTimeEnergyDischarged", 0.0)
else:
charge_energy = 0.0
discharge_energy = 0.0
total_charge_energy += charge_energy
total_discharge_energy += discharge_energy
self.data[f"{serial}_charge_energy"] = charge_energy
self.data[f"{serial}_discharge_energy"] = discharge_energy
self.data["charge_energy"] = total_charge_energy
self.data["discharge_energy"] = total_discharge_energy
LOGGER.debug("Updated SolarEdge storage data: %s", self.data)
class SolarEdgeModulesCoordinator(DataUpdateCoordinator[None]):
"""Handle fetching SolarEdge Modules data and inserting statistics."""
+191 -13
View File
@@ -22,7 +22,7 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
)
from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN
from .const import CONF_SITE_ID, DATA_API_CLIENT, DOMAIN, LOGGER
from .coordinator import (
SolarEdgeDataService,
SolarEdgeDetailsDataService,
@@ -30,6 +30,7 @@ from .coordinator import (
SolarEdgeInventoryDataService,
SolarEdgeOverviewDataService,
SolarEdgePowerFlowDataService,
SolarEdgeStorageDataService,
)
from .types import SolarEdgeConfigEntry
@@ -207,6 +208,64 @@ SENSOR_TYPES = [
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
),
SolarEdgeSensorEntityDescription(
key="storage_charge_energy",
json_key="charge_energy",
translation_key="storage_charge_energy",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
SolarEdgeSensorEntityDescription(
key="storage_discharge_energy",
json_key="discharge_energy",
translation_key="storage_discharge_energy",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
]
# Per-battery sensor descriptions, created dynamically per serial number
BATTERY_SENSOR_TYPES = [
SolarEdgeSensorEntityDescription(
key="battery_charge_energy",
json_key="charge_energy",
translation_key="battery_charge_energy",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
SolarEdgeSensorEntityDescription(
key="battery_discharge_energy",
json_key="discharge_energy",
translation_key="battery_discharge_energy",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
),
SolarEdgeSensorEntityDescription(
key="battery_state_of_charge",
json_key="state_of_charge",
translation_key="battery_state_of_charge",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
),
SolarEdgeSensorEntityDescription(
key="battery_power",
json_key="power",
translation_key="battery_power",
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
),
]
@@ -222,15 +281,43 @@ async def async_setup_entry(
api = entry.runtime_data[DATA_API_CLIENT]
sensor_factory = SolarEdgeSensorFactory(hass, entry, entry.data[CONF_SITE_ID], api)
# Set up and refresh base services first
for service in sensor_factory.all_services:
service.async_setup()
await service.coordinator.async_refresh()
entities = []
entities: list[SolarEdgeSensorEntity] = []
# Set up storage sensors only if inventory shows batteries are present
storage_result = sensor_factory.setup_storage_sensors()
if storage_result is not None:
if storage_result:
await sensor_factory.storage_service.coordinator.async_refresh()
entities.extend(storage_result)
else:
# Inventory fetch failed, register listener to retry when data arrives
def on_inventory_update() -> None:
"""Handle inventory update to set up storage sensors."""
result = sensor_factory.setup_storage_sensors()
if result is not None:
if result:
hass.async_create_task(
sensor_factory.storage_service.coordinator.async_refresh()
)
async_add_entities(result)
# Success or confirmed no batteries - stop listening
unsub()
unsub = sensor_factory.inventory_service.coordinator.async_add_listener(
on_inventory_update
)
entry.async_on_unload(unsub)
for sensor_type in SENSOR_TYPES:
sensor = sensor_factory.create_sensor(sensor_type)
if sensor is not None:
entities.append(sensor)
if sensor_type.key in ("storage_charge_energy", "storage_discharge_energy"):
continue
entities.append(sensor_factory.create_sensor(sensor_type))
async_add_entities(entities)
@@ -251,8 +338,17 @@ class SolarEdgeSensorFactory:
inventory = SolarEdgeInventoryDataService(hass, config_entry, api, site_id)
flow = SolarEdgePowerFlowDataService(hass, config_entry, api, site_id)
energy = SolarEdgeEnergyDetailsService(hass, config_entry, api, site_id)
storage = SolarEdgeStorageDataService(hass, config_entry, api, site_id)
self.all_services = (details, overview, inventory, flow, energy)
self.all_services: list[SolarEdgeDataService] = [
details,
overview,
inventory,
flow,
energy,
]
self.inventory_service = inventory
self.storage_service = storage
self.services: dict[
str,
@@ -289,6 +385,56 @@ class SolarEdgeSensorFactory:
):
self.services[key] = (SolarEdgeEnergyDetailsSensor, energy)
def setup_storage_sensors(
self,
) -> list[SolarEdgeSensorEntity] | None:
"""Set up storage sensors if batteries are available.
Returns:
list: Storage sensor entities to add (empty if no batteries)
None: Inventory fetch failed, should retry later
"""
# Check if inventory data was successfully fetched
if not self.inventory_service.coordinator.last_update_success:
LOGGER.debug("Inventory data not available, will retry later")
return None
battery_attr = self.inventory_service.attributes.get("batteries", {})
inventory_batteries = battery_attr.get("batteries", [])
if not inventory_batteries:
LOGGER.debug("No batteries found in inventory, skipping storage sensors")
return []
# Set up storage service and add to services
self.storage_service.async_setup()
self.all_services.append(self.storage_service)
for key in ("storage_charge_energy", "storage_discharge_energy"):
self.services[key] = (SolarEdgeStorageDataSensor, self.storage_service)
# Create aggregate storage sensors
storage_entities: list[SolarEdgeSensorEntity] = [
self.create_sensor(sensor_type)
for sensor_type in SENSOR_TYPES
if sensor_type.key in ("storage_charge_energy", "storage_discharge_energy")
]
# Create per-battery entities
for battery in inventory_batteries:
serial = battery.get("SN") or battery.get("serialNumber")
if not serial:
LOGGER.debug("Skipping battery without serial number in inventory")
continue
storage_entities.extend(
SolarEdgeBatterySensor(sensor_type, self.storage_service, serial)
for sensor_type in BATTERY_SENSOR_TYPES
)
LOGGER.debug(
"Storage sensors enabled, found %d batteries", len(inventory_batteries)
)
return storage_entities
def create_sensor(
self, sensor_type: SolarEdgeSensorEntityDescription
) -> SolarEdgeSensorEntity:
@@ -316,17 +462,11 @@ class SolarEdgeSensorEntity(
super().__init__(data_service.coordinator)
self.entity_description = description
self.data_service = data_service
self._attr_unique_id = f"{data_service.site_id}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data_service.site_id)}, manufacturer="SolarEdge"
)
@property
def unique_id(self) -> str | None:
"""Return a unique ID."""
if not self.data_service.site_id:
return None
return f"{self.data_service.site_id}_{self.entity_description.key}"
class SolarEdgeOverviewSensor(SolarEdgeSensorEntity):
"""Representation of an SolarEdge Monitoring API overview sensor."""
@@ -434,3 +574,41 @@ class SolarEdgeStorageLevelSensor(SolarEdgeSensorEntity):
if attr and "soc" in attr:
return attr["soc"]
return None
class SolarEdgeStorageDataSensor(SolarEdgeSensorEntity):
"""Representation of an SolarEdge aggregate storage data sensor."""
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.data_service.data.get(self.entity_description.json_key)
class SolarEdgeBatterySensor(SolarEdgeSensorEntity):
"""Representation of a per-battery SolarEdge sensor."""
def __init__(
self,
description: SolarEdgeSensorEntityDescription,
data_service: SolarEdgeStorageDataService,
serial: str,
) -> None:
"""Initialize the per-battery sensor."""
super().__init__(description, data_service)
self._serial = serial
self._attr_unique_id = f"{data_service.site_id}_{serial}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{data_service.site_id}_{serial}")},
manufacturer="SolarEdge",
name=f"Battery {serial}",
serial_number=serial,
via_device=(DOMAIN, data_service.site_id),
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.data_service.data.get(
f"{self._serial}_{self.entity_description.json_key}"
)
@@ -85,6 +85,18 @@
"batteries": {
"name": "Batteries"
},
"battery_charge_energy": {
"name": "Charge energy today"
},
"battery_discharge_energy": {
"name": "Discharge energy today"
},
"battery_power": {
"name": "Power"
},
"battery_state_of_charge": {
"name": "State of charge"
},
"consumption_energy": {
"name": "Consumed energy"
},
@@ -139,6 +151,12 @@
"solar_power": {
"name": "Solar power"
},
"storage_charge_energy": {
"name": "Storage charge energy today"
},
"storage_discharge_energy": {
"name": "Storage discharge energy today"
},
"storage_level": {
"name": "Storage level"
},
@@ -2,7 +2,7 @@
import asyncio
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -41,7 +41,7 @@ def enable_all_entities(entity_registry_enabled_by_default: None) -> None:
@patch("homeassistant.components.solaredge.SolarEdge")
async def test_solaredgeoverviewdataservice_energy_values_validity(
mock_solaredge,
mock_solaredge: MagicMock,
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
+503
View File
@@ -0,0 +1,503 @@
"""Tests for the SolarEdge sensors."""
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
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.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .conftest import API_KEY, 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,
},
],
}
]
}
}
STORAGE_DATA_MULTI_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,
},
],
},
{
"serialNumber": "BAT002",
"telemetries": [
{
"timeStamp": "2025-01-01 00:00:00",
"lifeTimeEnergyCharged": 2000.0,
"lifeTimeEnergyDischarged": 1000.0,
"batteryPercentageState": 40.0,
"power": 150.0,
},
{
"timeStamp": "2025-01-01 12:00:00",
"lifeTimeEnergyCharged": 2700.0,
"lifeTimeEnergyDischarged": 1400.0,
"batteryPercentageState": 80.0,
"power": 250.0,
},
],
},
]
}
}
@patch("homeassistant.components.solaredge.SolarEdge")
async def test_storage_data_service(
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 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()
# Aggregate sensors
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 float(state.state) == 500.0 # 1500 - 1000
state = hass.states.get(discharge_entry)
assert state is not None
assert float(state.state) == 300.0 # 800 - 500
# Per-battery entities
bat_charge = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_charge_energy"
)
bat_discharge = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_discharge_energy"
)
bat_soc = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_state_of_charge"
)
bat_power = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_power"
)
assert bat_charge is not None
assert bat_discharge is not None
assert bat_soc is not None
assert bat_power is not None
state = hass.states.get(bat_charge)
assert state is not None
assert float(state.state) == 500.0
state = hass.states.get(bat_discharge)
assert state is not None
assert float(state.state) == 300.0
state = hass.states.get(bat_soc)
assert state is not None
assert float(state.state) == 75.0
state = hass.states.get(bat_power)
assert state is not None
assert float(state.state) == 200.0
@patch("homeassistant.components.solaredge.SolarEdge")
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,
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
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"
)
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
# 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
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
# Per-battery entities for BAT002
bat2_charge = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_BAT002_battery_charge_energy"
)
bat2_soc = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_BAT002_battery_state_of_charge"
)
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
@patch("homeassistant.components.solaredge.SolarEdge")
async def test_storage_data_service_no_batteries(
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 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
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# 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"
)
discharge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy"
)
assert charge_entry is None
assert discharge_entry is None
@patch("homeassistant.components.solaredge.SolarEdge")
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,
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
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"
)
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
# 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"
@patch("homeassistant.components.solaredge.SolarEdge")
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,
entity_registry: er.EntityRegistry,
) -> 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
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"
)
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
# 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"
@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")
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,
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
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# 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
)
# Trigger inventory coordinator refresh
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
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
@patch("homeassistant.components.solaredge.SolarEdge")
async def test_storage_service_not_created_when_inventory_has_no_batteries(
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 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
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Inventory recovers but reports zero batteries
mock_solaredge_api.get_inventory = AsyncMock(
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"
)
assert charge_entry is None