mirror of
https://github.com/home-assistant/core.git
synced 2026-05-15 13:01:22 +01:00
759ac2eacd
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: it-rec <19797875+it-rec@users.noreply.github.com>
504 lines
17 KiB
Python
504 lines
17 KiB
Python
"""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
|