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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user