diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 35a14091e68..e2e141402fb 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -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) diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index ed3bff8cea2..9fb33a755f3 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -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.""" diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index b56c35be160..096b1eed70d 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -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}" + ) diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index 2dd02f70ade..0225262e973 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -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" }, diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 5e21a39febc..982ccad2c92 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -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, diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py new file mode 100644 index 00000000000..0dd514d0e80 --- /dev/null +++ b/tests/components/solaredge/test_sensor.py @@ -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