1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-18 06:20:17 +01:00
Files
2026-05-17 17:14:26 -04:00

702 lines
24 KiB
Python

"""Tests for the SolarEdge sensor platform."""
from unittest.mock import AsyncMock, Mock, patch
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.recorder import Recorder
from homeassistant.components.solaredge.const import DOMAIN, INVENTORY_UPDATE_DELAY
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import SITE_ID
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
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,
},
],
},
]
}
}
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
recorder_mock: Recorder,
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test all sensors are created with correct state and registry."""
with patch("homeassistant.components.solaredge.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_overview_sensors_unavailable_on_api_error(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test overview-based sensors are unavailable when overview API fails."""
solaredge_api.get_overview.side_effect = ClientError()
await setup_integration(hass, mock_config_entry)
for sensor_id in (
"sensor.solaredge_lifetime_energy",
"sensor.solaredge_energy_this_year",
"sensor.solaredge_energy_this_month",
"sensor.solaredge_energy_today",
"sensor.solaredge_current_power",
):
state = hass.states.get(sensor_id)
assert state is not None, sensor_id
assert state.state == STATE_UNAVAILABLE, sensor_id
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_level_unknown_when_storage_missing(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test storage_level returns None when site has no storage."""
power_flow = solaredge_api.get_current_power_flow.return_value
power_flow["siteCurrentPowerFlow"].pop("STORAGE")
# Drop STORAGE from connections too so the data service does not reference it.
power_flow["siteCurrentPowerFlow"]["connections"] = [
{"from": "GRID", "to": "Load"},
{"from": "PV", "to": "Load"},
]
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.solaredge_storage_level")
assert state is not None
assert state.state == STATE_UNKNOWN
async def test_no_sensors_without_api_key(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry_web_login: MockConfigEntry,
solaredge_web_api: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test no sensors are created when only web login auth is configured."""
await setup_integration(hass, mock_config_entry_web_login)
entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry_web_login.entry_id
)
assert entries == []
@pytest.mark.parametrize(
("api_method", "sensor_id"),
[
("get_overview", "sensor.solaredge_lifetime_energy"),
("get_inventory", "sensor.solaredge_inverters"),
("get_current_power_flow", "sensor.solaredge_grid_power"),
("get_energy_details", "sensor.solaredge_produced_energy"),
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_unavailable_on_data_service_keyerror(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
api_method: str,
sensor_id: str,
) -> None:
"""Test sensors become unavailable on UpdateFailed."""
getattr(solaredge_api, api_method).return_value = {}
await setup_integration(hass, mock_config_entry)
state = hass.states.get(sensor_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_details_sensor_unavailable_on_data_service_keyerror(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test the details sensor becomes unavailable when its refresh fails.
`get_details` is also called during setup validation, so the first call
must succeed; subsequent calls (the data service refresh) return data
without the 'details' key to trigger UpdateFailed.
"""
solaredge_api.get_details.side_effect = [
{"details": {"status": "Active"}},
{},
]
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.solaredge_site_details")
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_energy_details_sensor_unknown_when_no_meters(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test energy detail sensors stay unknown when the API reports no meters."""
solaredge_api.get_energy_details.return_value = {"energyDetails": {"unit": "Wh"}}
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.solaredge_produced_energy")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_energy_details_filters_meters(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test energy details skips meters without type/values."""
solaredge_api.get_energy_details.return_value = {
"energyDetails": {
"unit": "Wh",
"meters": [
{"type": "Production"}, # missing values, skipped
{"values": [{"date": "2025-01-01", "value": 1.0}]}, # missing type
{
"type": "SomethingElse", # unsupported type, skipped
"values": [{"date": "2025-01-01", "value": 2.0}],
},
{
"type": "Production",
"values": [{"date": "2025-01-01", "value": 100.0}],
},
],
}
}
await setup_integration(hass, mock_config_entry)
produced = hass.states.get("sensor.solaredge_produced_energy")
assert produced is not None
assert produced.state == "100.0"
assert produced.attributes["date"] == "2025-01-01"
consumed = hass.states.get("sensor.solaredge_consumed_energy")
assert consumed is not None
assert consumed.state == STATE_UNKNOWN
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_power_flow_sensor_unknown_when_no_connections(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test power flow sensors stay unknown when the API reports no connections."""
solaredge_api.get_current_power_flow.return_value = {
"siteCurrentPowerFlow": {"unit": "W"}
}
await setup_integration(hass, mock_config_entry)
state = hass.states.get("sensor.solaredge_grid_power")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_power_flow_grid_export_storage_discharge(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test power flow sign flipping for grid export and storage."""
solaredge_api.get_current_power_flow.return_value = {
"siteCurrentPowerFlow": {
"unit": "W",
"connections": [
{"from": "PV", "to": "GRID"},
{"from": "STORAGE", "to": "Load"},
],
"GRID": {"status": "Active", "currentPower": 100.0},
"LOAD": {"status": "Active", "currentPower": 500.0},
"PV": {"status": "Active", "currentPower": 600.0},
"STORAGE": {
"status": "Discharging",
"currentPower": 400.0,
"chargeLevel": 60,
},
}
}
await setup_integration(hass, mock_config_entry)
grid = hass.states.get("sensor.solaredge_grid_power")
assert grid is not None
assert grid.state == "-100.0"
assert grid.attributes["flow"] == "export"
storage = hass.states.get("sensor.solaredge_storage_power")
assert storage is not None
assert storage.state == "400.0"
assert storage.attributes["flow"] == "discharge"
assert storage.attributes["soc"] == 60
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_power_flow_zero_current_power_keeps_zero(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
) -> None:
"""Test power flow leaves zero values untouched (no -0 in the state)."""
solaredge_api.get_current_power_flow.return_value = {
"siteCurrentPowerFlow": {
"unit": "W",
"connections": [{"from": "PV", "to": "GRID"}],
"GRID": {"status": "Idle", "currentPower": 0},
"STORAGE": {"status": "Idle", "currentPower": 0, "chargeLevel": 50},
}
}
await setup_integration(hass, mock_config_entry)
assert hass.states.get("sensor.solaredge_grid_power").state == "0"
assert hass.states.get("sensor.solaredge_storage_power").state == "0"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_data_service(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage data service fetches battery charge/discharge energy."""
await setup_integration(hass, mock_config_entry)
# 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 for BAT001
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
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_data_service_multi_battery(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage data service aggregates data across multiple batteries."""
inventory = solaredge_api.get_inventory.return_value
inventory["Inventory"]["batteries"] = [{"SN": "BAT001"}, {"SN": "BAT002"}]
solaredge_api.get_storage_data.return_value = STORAGE_DATA_MULTI_BATTERY
await setup_integration(hass, mock_config_entry)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
discharge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy"
)
assert charge_entry is not None
assert discharge_entry is not None
# BAT001: charge=500 (1500-1000), discharge=300 (800-500)
# BAT002: charge=700 (2700-2000), discharge=400 (1400-1000)
assert float(hass.states.get(charge_entry).state) == 1200.0
assert float(hass.states.get(discharge_entry).state) == 700.0
bat1_soc = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_state_of_charge"
)
assert bat1_soc is not None
assert float(hass.states.get(bat1_soc).state) == 75.0
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
assert float(hass.states.get(bat2_charge).state) == 700.0
assert float(hass.states.get(bat2_soc).state) == 80.0
async def test_storage_service_not_created_when_inventory_has_no_batteries(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage service is not created when no batteries in inventory."""
inventory = solaredge_api.get_inventory.return_value
inventory["Inventory"]["batteries"] = []
await setup_integration(hass, mock_config_entry)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
discharge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy"
)
assert charge_entry is None
assert discharge_entry is None
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_data_service_api_error(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage sensors are unavailable when the storage API errors out."""
solaredge_api.get_storage_data.side_effect = Exception("API error")
await setup_integration(hass, mock_config_entry)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
discharge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy"
)
assert charge_entry is not None
assert discharge_entry is not None
assert hass.states.get(charge_entry).state == STATE_UNAVAILABLE
assert hass.states.get(discharge_entry).state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
"bad_response",
[{"unexpected": {}}, {"storageData": {"otherField": "value"}}],
ids=["missing_storageData", "missing_batteries"],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_data_missing_keys_in_response(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
bad_response: dict,
) -> None:
"""Test storage sensors unavailable with missing required keys."""
solaredge_api.get_storage_data.return_value = bad_response
await setup_integration(hass, mock_config_entry)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
discharge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy"
)
assert charge_entry is not None
assert discharge_entry is not None
assert hass.states.get(charge_entry).state == STATE_UNAVAILABLE
assert hass.states.get(discharge_entry).state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_service_deferred_after_inventory_failure(
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage service is created after inventory recovers from a failure."""
valid_inventory = solaredge_api.get_inventory.return_value
solaredge_api.get_inventory.side_effect = KeyError("Inventory")
await setup_integration(hass, mock_config_entry)
# Storage sensors are not created yet — the inventory fetch failed.
assert (
entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
is None
)
# Inventory recovers and reports a battery → storage sensors get created.
solaredge_api.get_inventory.side_effect = None
solaredge_api.get_inventory.return_value = valid_inventory
freezer.tick(INVENTORY_UPDATE_DELAY)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert (
entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
is not None
)
assert (
entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy"
)
is not None
)
@pytest.mark.parametrize(
("storage_response", "expected_charge_state"),
[
# Empty batteries list → data service returns early, aggregate stays unset.
({"storageData": {"batteries": []}}, STATE_UNKNOWN),
# Battery missing the serialNumber key → skipped in the loop, aggregate
# falls through with the initial 0.0 totals.
({"storageData": {"batteries": [{"telemetries": []}]}}, "0.0"),
# Battery with no telemetries → skipped after the serial check.
(
{
"storageData": {
"batteries": [{"serialNumber": "BAT001", "telemetries": []}]
}
},
"0.0",
),
# Battery with a single telemetry → can't compute a delta, contributes
# 0.0 to the aggregate via the len < 2 branch.
(
{
"storageData": {
"batteries": [
{
"serialNumber": "BAT001",
"telemetries": [
{
"timeStamp": "2025-01-01 00:00:00",
"lifeTimeEnergyCharged": 1000.0,
"lifeTimeEnergyDischarged": 500.0,
"batteryPercentageState": 50.0,
"power": 100.0,
}
],
}
]
}
},
"0.0",
),
],
ids=[
"empty_batteries",
"battery_without_serial",
"battery_without_telemetries",
"battery_with_single_telemetry",
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_data_service_handles_malformed_responses(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
storage_response: dict,
expected_charge_state: str,
) -> None:
"""Test storage tolerates batteries without serial/telemetries."""
solaredge_api.get_storage_data.return_value = storage_response
await setup_integration(hass, mock_config_entry)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
assert charge_entry is not None
state = hass.states.get(charge_entry)
assert state is not None
assert state.state == expected_charge_state
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_inventory_battery_without_serial_skipped(
recorder_mock: Recorder,
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test batteries without serial are skipped for per-battery sensors."""
inventory = solaredge_api.get_inventory.return_value
inventory["Inventory"]["batteries"] = [{"name": "Battery without serial"}]
await setup_integration(hass, mock_config_entry)
# Aggregate sensors are still created (battery exists in inventory)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
assert charge_entry is not None
# No per-battery sensors because the battery has no serial.
# Per-battery unique_ids follow `{site_id}_{serial}_battery_{key}`.
per_battery_entries = [
e
for e in er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
if "_battery_" in e.unique_id
]
assert per_battery_entries == []
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_storage_service_not_retried_after_recovery_with_no_batteries(
recorder_mock: Recorder,
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
solaredge_api: Mock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test storage stays idle when inventory has no batteries."""
solaredge_api.get_inventory.side_effect = KeyError("Inventory")
await setup_integration(hass, mock_config_entry)
# Inventory recovers but reports zero batteries.
solaredge_api.get_inventory.side_effect = None
solaredge_api.get_inventory.return_value = {"Inventory": {"batteries": []}}
freezer.tick(INVENTORY_UPDATE_DELAY)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
charge_entry = entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy"
)
assert charge_entry is None