From fd7ffab79aa73169d2fe93a3a4743b6a569e9406 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 05:29:40 +0000 Subject: [PATCH 01/13] Improve test coverage for the SolarEdge integration Push test coverage to 100% across every source file with a mix of snapshot-based platform tests, integration-style coordinator failure tests and targeted unit tests: - New snapshot test exercising all 21 standard sensors plus the per-battery storage sensors via snapshot_platform(). - Integration-style coordinator tests for every data service covering KeyError -> UpdateFailed paths and the early-return branches (no meters / no connections / empty batteries / missing serial / missing or single telemetry). - Setup branch coverage in __init__.py for the missing-details and inactive-site paths plus the modules coordinator first-refresh failure. - Power-flow sign-flip tests (grid export, storage discharge) and the zero-power no-flip edge case. - Storage tests adapted from the recent battery storage PR to use the shared conftest fixtures and setup_integration helper. - Drop two unreachable defensive branches in sensor.py: an early-return in async_setup_entry guarded by DATA_API_CLIENT (the platform is only forwarded when the API client is set) and a unique_id property on SolarEdgeDetailsSensor that returned None when site_id was empty - replaced with an _attr_unique_id assigned in the constructor. Coverage: Name Stmts Miss Cover ----------------------------------------------------------------------- homeassistant/components/solaredge/__init__.py 40 0 100% homeassistant/components/solaredge/config_flow.py 102 0 100% homeassistant/components/solaredge/const.py 19 0 100% homeassistant/components/solaredge/coordinator.py 271 0 100% homeassistant/components/solaredge/sensor.py 163 0 100% homeassistant/components/solaredge/types.py 9 0 100% ----------------------------------------------------------------------- TOTAL 604 0 100% --- homeassistant/components/solaredge/sensor.py | 20 +- tests/components/solaredge/__init__.py | 13 + tests/components/solaredge/conftest.py | 50 +- .../solaredge/fixtures/details.json | 19 + .../solaredge/fixtures/energy_details.json | 28 + .../solaredge/fixtures/inventory.json | 20 + .../solaredge/fixtures/overview.json | 11 + .../solaredge/fixtures/power_flow.json | 15 + .../solaredge/fixtures/storage_data.json | 25 + .../solaredge/snapshots/test_sensor.ambr | 1582 +++++++++++++++++ .../components/solaredge/test_coordinator.py | 206 ++- tests/components/solaredge/test_init.py | 34 + tests/components/solaredge/test_sensor.py | 479 +++-- 13 files changed, 2238 insertions(+), 264 deletions(-) create mode 100644 tests/components/solaredge/fixtures/details.json create mode 100644 tests/components/solaredge/fixtures/energy_details.json create mode 100644 tests/components/solaredge/fixtures/inventory.json create mode 100644 tests/components/solaredge/fixtures/overview.json create mode 100644 tests/components/solaredge/fixtures/power_flow.json create mode 100644 tests/components/solaredge/fixtures/storage_data.json create mode 100644 tests/components/solaredge/snapshots/test_sensor.ambr diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 096b1eed70dc69..e3f1a996ba783c 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -275,10 +275,6 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add an solarEdge entry.""" - # Add sensor entities only if API key is configured - if DATA_API_CLIENT not in entry.runtime_data: - return - api = entry.runtime_data[DATA_API_CLIENT] sensor_factory = SolarEdgeSensorFactory(hass, entry, entry.data[CONF_SITE_ID], api) @@ -480,6 +476,15 @@ def native_value(self) -> str | None: class SolarEdgeDetailsSensor(SolarEdgeSensorEntity): """Representation of an SolarEdge Monitoring API details sensor.""" + def __init__( + self, + description: SolarEdgeSensorEntityDescription, + data_service: SolarEdgeDataService, + ) -> None: + """Initialize the sensor.""" + super().__init__(description, data_service) + self._attr_unique_id = str(data_service.site_id) + @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" @@ -490,13 +495,6 @@ def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data.get(self.entity_description.json_key) - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - if not self.data_service.site_id: - return None - return str(self.data_service.site_id) - class SolarEdgeInventorySensor(SolarEdgeSensorEntity): """Representation of an SolarEdge Monitoring API inventory sensor.""" diff --git a/tests/components/solaredge/__init__.py b/tests/components/solaredge/__init__.py index c2a54cfafb623c..010251296101ff 100644 --- a/tests/components/solaredge/__init__.py +++ b/tests/components/solaredge/__init__.py @@ -1 +1,14 @@ """Tests for the SolarEdge component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the SolarEdge integration in Home Assistant.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/solaredge/conftest.py b/tests/components/solaredge/conftest.py index 664716be1cf67c..21cd8153d51c62 100644 --- a/tests/components/solaredge/conftest.py +++ b/tests/components/solaredge/conftest.py @@ -5,6 +5,11 @@ import pytest +from homeassistant.components.solaredge.const import CONF_SITE_ID, DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry, load_json_object_fixture + SITE_ID = "1a2b3c4d5e6f7g8h" API_KEY = "a1b2c3d4e5f6g7h8" USERNAME = "test-username" @@ -21,11 +26,54 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a MockConfigEntry with API key auth.""" + return MockConfigEntry( + domain=DOMAIN, + title="SolarEdge", + unique_id=SITE_ID, + data={CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, + ) + + +@pytest.fixture +def mock_config_entry_web_login() -> MockConfigEntry: + """Return a MockConfigEntry with web login auth.""" + return MockConfigEntry( + domain=DOMAIN, + title="SolarEdge", + unique_id=SITE_ID, + data={ + CONF_SITE_ID: SITE_ID, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + @pytest.fixture(name="solaredge_api") def mock_solaredge_api_fixture() -> Generator[Mock]: """Mock a successful SolarEdge Monitoring API.""" api = Mock() - api.get_details = AsyncMock(return_value={"details": {"status": "active"}}) + api.get_details = AsyncMock( + return_value=load_json_object_fixture("details.json", DOMAIN) + ) + api.get_overview = AsyncMock( + return_value=load_json_object_fixture("overview.json", DOMAIN) + ) + api.get_inventory = AsyncMock( + return_value=load_json_object_fixture("inventory.json", DOMAIN) + ) + api.get_current_power_flow = AsyncMock( + return_value=load_json_object_fixture("power_flow.json", DOMAIN) + ) + api.get_energy_details = AsyncMock( + return_value=load_json_object_fixture("energy_details.json", DOMAIN) + ) + api.get_storage_data = AsyncMock( + return_value=load_json_object_fixture("storage_data.json", DOMAIN) + ) with ( patch( "homeassistant.components.solaredge.config_flow.aiosolaredge.SolarEdge", diff --git a/tests/components/solaredge/fixtures/details.json b/tests/components/solaredge/fixtures/details.json new file mode 100644 index 00000000000000..a17e31a27a0d8d --- /dev/null +++ b/tests/components/solaredge/fixtures/details.json @@ -0,0 +1,19 @@ +{ + "details": { + "id": 1, + "name": "Test Site", + "accountId": 1, + "status": "Active", + "peakPower": 5.5, + "lastUpdateTime": "2025-01-01", + "installationDate": "2024-01-01", + "ptoDate": null, + "notes": "", + "type": "Optimizers & Inverters", + "primaryModule": { + "manufacturerName": "TestManufacturer", + "modelName": "TestModel", + "maximumPower": 350.0 + } + } +} diff --git a/tests/components/solaredge/fixtures/energy_details.json b/tests/components/solaredge/fixtures/energy_details.json new file mode 100644 index 00000000000000..b51fa2d301051c --- /dev/null +++ b/tests/components/solaredge/fixtures/energy_details.json @@ -0,0 +1,28 @@ +{ + "energyDetails": { + "timeUnit": "DAY", + "unit": "Wh", + "meters": [ + { + "type": "Production", + "values": [{"date": "2025-01-01 00:00:00", "value": 12345.0}] + }, + { + "type": "Consumption", + "values": [{"date": "2025-01-01 00:00:00", "value": 9876.0}] + }, + { + "type": "SelfConsumption", + "values": [{"date": "2025-01-01 00:00:00", "value": 5432.0}] + }, + { + "type": "FeedIn", + "values": [{"date": "2025-01-01 00:00:00", "value": 6913.0}] + }, + { + "type": "Purchased", + "values": [{"date": "2025-01-01 00:00:00", "value": 4444.0}] + } + ] + } +} diff --git a/tests/components/solaredge/fixtures/inventory.json b/tests/components/solaredge/fixtures/inventory.json new file mode 100644 index 00000000000000..3fa6a1731f3c28 --- /dev/null +++ b/tests/components/solaredge/fixtures/inventory.json @@ -0,0 +1,20 @@ +{ + "Inventory": { + "meters": [ + {"name": "Production meter", "manufacturer": "Acme", "model": "M1"}, + {"name": "Consumption meter", "manufacturer": "Acme", "model": "M1"} + ], + "sensors": [], + "gateways": [ + {"name": "Gateway1", "serialNumber": "G-001"} + ], + "batteries": [ + {"name": "Battery1", "manufacturer": "Acme", "model": "B1", "SN": "BAT001", "nameplateCapacity": 9700.0} + ], + "inverters": [ + {"name": "Inverter1", "manufacturer": "Acme", "model": "I1", "serialNumber": "INV-001"}, + {"name": "Inverter2", "manufacturer": "Acme", "model": "I1", "serialNumber": "INV-002"}, + {"name": "Inverter3", "manufacturer": "Acme", "model": "I1", "serialNumber": "INV-003"} + ] + } +} diff --git a/tests/components/solaredge/fixtures/overview.json b/tests/components/solaredge/fixtures/overview.json new file mode 100644 index 00000000000000..1914f4c6f5533a --- /dev/null +++ b/tests/components/solaredge/fixtures/overview.json @@ -0,0 +1,11 @@ +{ + "overview": { + "lastUpdateTime": "2025-01-01 12:00:00", + "lifeTimeData": {"energy": 100000.0, "revenue": 10.0}, + "lastYearData": {"energy": 50000.0}, + "lastMonthData": {"energy": 10000.0}, + "lastDayData": {"energy": 250.0}, + "currentPower": {"power": 1234.5}, + "measuredBy": "INVERTER" + } +} diff --git a/tests/components/solaredge/fixtures/power_flow.json b/tests/components/solaredge/fixtures/power_flow.json new file mode 100644 index 00000000000000..2dee7032bb0a1b --- /dev/null +++ b/tests/components/solaredge/fixtures/power_flow.json @@ -0,0 +1,15 @@ +{ + "siteCurrentPowerFlow": { + "updateRefreshRate": 3, + "unit": "W", + "connections": [ + {"from": "GRID", "to": "Load"}, + {"from": "PV", "to": "Load"}, + {"from": "PV", "to": "STORAGE"} + ], + "GRID": {"status": "Active", "currentPower": 250.0}, + "LOAD": {"status": "Active", "currentPower": 1500.0}, + "PV": {"status": "Active", "currentPower": 1500.0}, + "STORAGE": {"status": "Charging", "currentPower": 250.0, "chargeLevel": 75, "critical": false} + } +} diff --git a/tests/components/solaredge/fixtures/storage_data.json b/tests/components/solaredge/fixtures/storage_data.json new file mode 100644 index 00000000000000..21f4065467139b --- /dev/null +++ b/tests/components/solaredge/fixtures/storage_data.json @@ -0,0 +1,25 @@ +{ + "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 + } + ] + } + ] + } +} diff --git a/tests/components/solaredge/snapshots/test_sensor.ambr b/tests/components/solaredge/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..679a530201f3f0 --- /dev/null +++ b/tests/components/solaredge/snapshots/test_sensor.ambr @@ -0,0 +1,1582 @@ +# serializer version: 1 +# name: test_all_entities[sensor.battery_bat001_charge_energy_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_bat001_charge_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charge energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge energy today', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_charge_energy', + 'unique_id': '1a2b3c4d5e6f7g8h_BAT001_battery_charge_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.battery_bat001_charge_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Battery BAT001 Charge energy today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_bat001_charge_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '500.0', + }) +# --- +# name: test_all_entities[sensor.battery_bat001_discharge_energy_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_bat001_discharge_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Discharge energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharge energy today', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_discharge_energy', + 'unique_id': '1a2b3c4d5e6f7g8h_BAT001_battery_discharge_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.battery_bat001_discharge_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Battery BAT001 Discharge energy today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_bat001_discharge_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '300.0', + }) +# --- +# name: test_all_entities[sensor.battery_bat001_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_bat001_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': '1a2b3c4d5e6f7g8h_BAT001_battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.battery_bat001_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Battery BAT001 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_bat001_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '200.0', + }) +# --- +# name: test_all_entities[sensor.battery_bat001_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_bat001_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'State of charge', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state_of_charge', + 'unique_id': '1a2b3c4d5e6f7g8h_BAT001_battery_state_of_charge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.battery_bat001_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Battery BAT001 State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.battery_bat001_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_batteries-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_batteries', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Batteries', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Batteries', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'batteries', + 'unique_id': '1a2b3c4d5e6f7g8h_batteries', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.solaredge_batteries-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'batteries': list([ + dict({ + 'SN': 'BAT001', + 'manufacturer': 'Acme', + 'model': 'B1', + 'name': 'Battery1', + 'nameplateCapacity': 9700.0, + }), + ]), + 'friendly_name': 'SolarEdge Batteries', + }), + 'context': , + 'entity_id': 'sensor.solaredge_batteries', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[sensor.solaredge_consumed_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_consumed_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Consumed energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumed energy', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_energy', + 'unique_id': '1a2b3c4d5e6f7g8h_consumption_energy', + 'unit_of_measurement': 'Wh', + }) +# --- +# name: test_all_entities[sensor.solaredge_consumed_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'date': '2025-01-01 00:00:00', + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Consumed energy', + 'state_class': , + 'unit_of_measurement': 'Wh', + }), + 'context': , + 'entity_id': 'sensor.solaredge_consumed_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9876.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_current_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_current_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current power', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_power', + 'unique_id': '1a2b3c4d5e6f7g8h_current_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solaredge_current_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarEdge Current power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solaredge_current_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1234.5', + }) +# --- +# name: test_all_entities[sensor.solaredge_energy_this_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_energy_this_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy this month', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy this month', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_this_month', + 'unique_id': '1a2b3c4d5e6f7g8h_energy_this_month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solaredge_energy_this_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Energy this month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solaredge_energy_this_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10000.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_energy_this_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_energy_this_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy this year', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy this year', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_this_year', + 'unique_id': '1a2b3c4d5e6f7g8h_energy_this_year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solaredge_energy_this_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Energy this year', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solaredge_energy_this_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50000.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_energy_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy today', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1a2b3c4d5e6f7g8h_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solaredge_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Energy today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solaredge_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_exported_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_exported_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Exported energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exported energy', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'feedin_energy', + 'unique_id': '1a2b3c4d5e6f7g8h_feedin_energy', + 'unit_of_measurement': 'Wh', + }) +# --- +# name: test_all_entities[sensor.solaredge_exported_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'date': '2025-01-01 00:00:00', + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Exported energy', + 'state_class': , + 'unit_of_measurement': 'Wh', + }), + 'context': , + 'entity_id': 'sensor.solaredge_exported_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6913.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_gateways-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_gateways', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Gateways', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Gateways', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gateways', + 'unique_id': '1a2b3c4d5e6f7g8h_gateways', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.solaredge_gateways-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarEdge Gateways', + 'gateways': list([ + dict({ + 'name': 'Gateway1', + 'serialNumber': 'G-001', + }), + ]), + }), + 'context': , + 'entity_id': 'sensor.solaredge_gateways', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[sensor.solaredge_grid_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_grid_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid power', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_power', + 'unique_id': '1a2b3c4d5e6f7g8h_grid_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[sensor.solaredge_grid_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'flow': 'import', + 'friendly_name': 'SolarEdge Grid power', + 'state_class': , + 'status': 'Active', + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.solaredge_grid_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '250.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_imported_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_imported_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Imported energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Imported energy', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'purchased_energy', + 'unique_id': '1a2b3c4d5e6f7g8h_purchased_energy', + 'unit_of_measurement': 'Wh', + }) +# --- +# name: test_all_entities[sensor.solaredge_imported_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'date': '2025-01-01 00:00:00', + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Imported energy', + 'state_class': , + 'unit_of_measurement': 'Wh', + }), + 'context': , + 'entity_id': 'sensor.solaredge_imported_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4444.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_inverters-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_inverters', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Inverters', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Inverters', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'inverters', + 'unique_id': '1a2b3c4d5e6f7g8h_inverters', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.solaredge_inverters-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarEdge Inverters', + 'inverters': list([ + dict({ + 'manufacturer': 'Acme', + 'model': 'I1', + 'name': 'Inverter1', + 'serialNumber': 'INV-001', + }), + dict({ + 'manufacturer': 'Acme', + 'model': 'I1', + 'name': 'Inverter2', + 'serialNumber': 'INV-002', + }), + dict({ + 'manufacturer': 'Acme', + 'model': 'I1', + 'name': 'Inverter3', + 'serialNumber': 'INV-003', + }), + ]), + }), + 'context': , + 'entity_id': 'sensor.solaredge_inverters', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[sensor.solaredge_lifetime_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_lifetime_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lifetime energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1a2b3c4d5e6f7g8h_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solaredge_lifetime_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Lifetime energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solaredge_lifetime_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100000.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_meters-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_meters', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Meters', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Meters', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'meters', + 'unique_id': '1a2b3c4d5e6f7g8h_meters', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.solaredge_meters-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarEdge Meters', + 'meters': list([ + dict({ + 'manufacturer': 'Acme', + 'model': 'M1', + 'name': 'Production meter', + }), + dict({ + 'manufacturer': 'Acme', + 'model': 'M1', + 'name': 'Consumption meter', + }), + ]), + }), + 'context': , + 'entity_id': 'sensor.solaredge_meters', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[sensor.solaredge_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power consumption', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power consumption', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_consumption', + 'unique_id': '1a2b3c4d5e6f7g8h_power_consumption', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[sensor.solaredge_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarEdge Power consumption', + 'state_class': , + 'status': 'Active', + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.solaredge_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_produced_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_produced_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Produced energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Produced energy', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'production_energy', + 'unique_id': '1a2b3c4d5e6f7g8h_production_energy', + 'unit_of_measurement': 'Wh', + }) +# --- +# name: test_all_entities[sensor.solaredge_produced_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'date': '2025-01-01 00:00:00', + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Produced energy', + 'state_class': , + 'unit_of_measurement': 'Wh', + }), + 'context': , + 'entity_id': 'sensor.solaredge_produced_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12345.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_self_consumed_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_self_consumed_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Self-consumed energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Self-consumed energy', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'selfconsumption_energy', + 'unique_id': '1a2b3c4d5e6f7g8h_selfconsumption_energy', + 'unit_of_measurement': 'Wh', + }) +# --- +# name: test_all_entities[sensor.solaredge_self_consumed_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'date': '2025-01-01 00:00:00', + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Self-consumed energy', + 'state_class': , + 'unit_of_measurement': 'Wh', + }), + 'context': , + 'entity_id': 'sensor.solaredge_self_consumed_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5432.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_sensors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_sensors', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sensors', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensors', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sensors', + 'unique_id': '1a2b3c4d5e6f7g8h_sensors', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.solaredge_sensors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarEdge Sensors', + 'sensors': list([ + ]), + }), + 'context': , + 'entity_id': 'sensor.solaredge_sensors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.solaredge_site_details-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_site_details', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Site details', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Site details', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'site_details', + 'unique_id': '1a2b3c4d5e6f7g8h', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.solaredge_site_details-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SolarEdge Site details', + 'installation_date': '2024-01-01', + 'last_update_time': '2025-01-01', + 'manufacturer_name': 'TestManufacturer', + 'maximum_power': 350.0, + 'model_name': 'TestModel', + 'name': 'Test Site', + 'peak_power': 5.5, + 'type': 'Optimizers & Inverters', + }), + 'context': , + 'entity_id': 'sensor.solaredge_site_details', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Active', + }) +# --- +# name: test_all_entities[sensor.solaredge_solar_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_solar_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar power', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'solar_power', + 'unique_id': '1a2b3c4d5e6f7g8h_solar_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[sensor.solaredge_solar_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'SolarEdge Solar power', + 'state_class': , + 'status': 'Active', + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.solaredge_solar_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1500.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_storage_charge_energy_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_storage_charge_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage charge energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage charge energy today', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_charge_energy', + 'unique_id': '1a2b3c4d5e6f7g8h_storage_charge_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solaredge_storage_charge_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Storage charge energy today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solaredge_storage_charge_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '500.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_storage_discharge_energy_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_storage_discharge_energy_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage discharge energy today', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage discharge energy today', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_discharge_energy', + 'unique_id': '1a2b3c4d5e6f7g8h_storage_discharge_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.solaredge_storage_discharge_energy_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'SolarEdge Storage discharge energy today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solaredge_storage_discharge_energy_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '300.0', + }) +# --- +# name: test_all_entities[sensor.solaredge_storage_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_storage_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage level', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_level', + 'unique_id': '1a2b3c4d5e6f7g8h_storage_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.solaredge_storage_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'SolarEdge Storage level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.solaredge_storage_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- +# name: test_all_entities[sensor.solaredge_storage_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solaredge_storage_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Storage power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storage power', + 'platform': 'solaredge', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'storage_power', + 'unique_id': '1a2b3c4d5e6f7g8h_storage_power', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_all_entities[sensor.solaredge_storage_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'flow': 'charge', + 'friendly_name': 'SolarEdge Storage power', + 'soc': 75, + 'state_class': , + 'status': 'Charging', + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.solaredge_storage_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-250.0', + }) +# --- diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 982ccad2c92b08..ccaee6c7799a5f 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -2,8 +2,9 @@ import asyncio from datetime import datetime, timedelta -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory import pytest from solaredge_web import EnergyData @@ -18,16 +19,19 @@ OVERVIEW_UPDATE_DELAY, ) from homeassistant.components.solaredge.coordinator import SolarEdgeModulesCoordinator +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_API_KEY, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util +from . import setup_integration from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -417,3 +421,203 @@ async def test_modules_coordinator_no_energy_data( {"state", "sum"}, ) assert not stats + + +@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"), + ], +) +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 when a data service refresh raises 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 + + +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 + + +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 + + +async def test_energy_details_filters_meters( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + solaredge_api: Mock, +) -> None: + """Test energy details data service skips meters without type/values and unsupported types.""" + 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 + + +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 + + +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 reports for storage discharge.""" + 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 + + +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" + + +async def test_modules_coordinator_api_failure( + recorder_mock: Recorder, + hass: HomeAssistant, + solaredge_web_api: AsyncMock, +) -> None: + """Test the modules coordinator surfaces API failures via the config entry state.""" + solaredge_web_api.async_get_equipment.side_effect = ClientError("boom") + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SITE_ID: SITE_ID, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + await setup_integration(hass, entry) + + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/solaredge/test_init.py b/tests/components/solaredge/test_init.py index fca1bb7876b342..15e60db69f60e5 100644 --- a/tests/components/solaredge/test_init.py +++ b/tests/components/solaredge/test_init.py @@ -156,3 +156,37 @@ async def test_web_login_config_not_ready( await hass.async_block_till_done() assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_missing_details_key( + recorder_mock: Recorder, hass: HomeAssistant, solaredge_api: Mock +) -> None: + """Test setup raises ConfigEntryNotReady when API response has no 'details' key.""" + solaredge_api.get_details.return_value = {} + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_site_not_active( + recorder_mock: Recorder, hass: HomeAssistant, solaredge_api: Mock +) -> None: + """Test setup fails when the site status is not 'active'.""" + solaredge_api.get_details.return_value = {"details": {"status": "Disabled"}} + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py index 0dd514d0e80ba9..e299781ebc241c 100644 --- a/tests/components/solaredge/test_sensor.py +++ b/tests/components/solaredge/test_sensor.py @@ -1,100 +1,23 @@ -"""Tests for the SolarEdge sensors.""" +"""Tests for the SolarEdge sensor platform.""" -from unittest.mock import AsyncMock, MagicMock, patch +from datetime import timedelta +from unittest.mock import 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 ( - CONF_SITE_ID, - DEFAULT_NAME, - DOMAIN, - INVENTORY_UPDATE_DELAY, -) -from homeassistant.const import CONF_API_KEY, CONF_NAME +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 .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}, - ) - +from . import setup_integration +from .conftest import SITE_ID -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, - }, - ], - } - ] - } -} +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform STORAGE_DATA_MULTI_BATTERY = { "storageData": { @@ -142,22 +65,87 @@ def mock_config_entry() -> MockConfigEntry: } -@patch("homeassistant.components.solaredge.SolarEdge") +@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 sensor entities are created with the correct state and registry entry.""" + 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) + + +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) + + state = hass.states.get("sensor.solaredge_lifetime_energy") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@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 (unknown) when site has no storage in flow data.""" + 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: Mock, + 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.usefixtures("entity_registry_enabled_by_default") async def test_storage_data_service( - mock_solaredge: MagicMock, recorder_mock: Recorder, hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_solaredge_api: AsyncMock, mock_config_entry: MockConfigEntry, + solaredge_api: Mock, 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() + await setup_integration(hass, mock_config_entry) # Aggregate sensors charge_entry = entity_registry.async_get_entity_id( @@ -177,7 +165,7 @@ async def test_storage_data_service( assert state is not None assert float(state.state) == 300.0 # 800 - 500 - # Per-battery entities + # Per-battery entities for BAT001 bat_charge = entity_registry.async_get_entity_id( "sensor", DOMAIN, f"{SITE_ID}_BAT001_battery_charge_energy" ) @@ -212,28 +200,20 @@ async def test_storage_data_service( assert float(state.state) == 200.0 -@patch("homeassistant.components.solaredge.SolarEdge") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") 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, + solaredge_api: Mock, 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 + 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 - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) charge_entry = entity_registry.async_get_entity_id( "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" @@ -246,24 +226,15 @@ async def test_storage_data_service_multi_battery( # 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 + assert float(hass.states.get(charge_entry).state) == 1200.0 + assert float(hass.states.get(discharge_entry).state) == 700.0 - # 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 + assert float(hass.states.get(bat1_soc).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" ) @@ -272,37 +243,23 @@ async def test_storage_data_service_multi_battery( ) 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 + assert float(hass.states.get(bat2_charge).state) == 700.0 + assert float(hass.states.get(bat2_soc).state) == 80.0 -@patch("homeassistant.components.solaredge.SolarEdge") -async def test_storage_data_service_no_batteries( - mock_solaredge: MagicMock, +async def test_storage_service_not_created_when_inventory_has_no_batteries( recorder_mock: Recorder, hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_solaredge_api: AsyncMock, mock_config_entry: MockConfigEntry, + solaredge_api: Mock, 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 + inventory = solaredge_api.get_inventory.return_value + inventory["Inventory"]["batteries"] = [] - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) - # 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" ) @@ -313,23 +270,18 @@ async def test_storage_data_service_no_batteries( assert discharge_entry is None -@patch("homeassistant.components.solaredge.SolarEdge") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") 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, + solaredge_api: Mock, 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 + """Test storage sensors are unavailable when the storage API errors out.""" + solaredge_api.get_storage_data.side_effect = Exception("API error") - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) charge_entry = entity_registry.async_get_entity_id( "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" @@ -340,163 +292,188 @@ async def test_storage_data_service_api_error( 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" + assert hass.states.get(charge_entry).state == STATE_UNAVAILABLE + assert hass.states.get(discharge_entry).state == STATE_UNAVAILABLE - state = hass.states.get(discharge_entry) - assert state is not None - assert state.state == "unavailable" - -@patch("homeassistant.components.solaredge.SolarEdge") +@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( - mock_solaredge: MagicMock, recorder_mock: Recorder, hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_solaredge_api: AsyncMock, mock_config_entry: MockConfigEntry, + solaredge_api: Mock, entity_registry: er.EntityRegistry, + bad_response: dict, ) -> 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 + """Test storage sensors are unavailable when the response is missing required keys.""" + solaredge_api.get_storage_data.return_value = bad_response - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + 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 - # 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, +@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_solaredge_api: AsyncMock, mock_config_entry: MockConfigEntry, + solaredge_api: Mock, 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"}} + """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) + + charge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" ) - mock_solaredge.return_value = mock_solaredge_api + assert charge_entry is None - 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 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 + timedelta(seconds=1)) + 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" ) + discharge_entry = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) assert charge_entry is not None - - state = hass.states.get(charge_entry) - assert state is not None - assert state.state == "unavailable" + assert discharge_entry is not None -@patch("homeassistant.components.solaredge.SolarEdge") -async def test_storage_service_deferred_after_inventory_failure( - mock_solaredge: MagicMock, +@pytest.mark.parametrize( + "storage_response", + [ + # Empty batteries list inside the storage data + {"storageData": {"batteries": []}}, + # Battery missing the serialNumber key — skipped by the data service + {"storageData": {"batteries": [{"telemetries": []}]}}, + # Battery with no telemetries — skipped after the serial check + {"storageData": {"batteries": [{"serialNumber": "BAT001", "telemetries": []}]}}, + # Battery with a single telemetry — falls into 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, + } + ], + } + ] + } + }, + ], + 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, - freezer: FrozenDateTimeFactory, - mock_solaredge_api: AsyncMock, mock_config_entry: MockConfigEntry, + solaredge_api: Mock, entity_registry: er.EntityRegistry, + storage_response: dict, ) -> 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 + """Test storage data service tolerates batteries without serial / telemetries / single telemetry.""" + solaredge_api.get_storage_data.return_value = storage_response - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) - # Storage sensors should not exist yet + # Aggregate sensors are still created (inventory has a battery), but their + # state should be 0.0 (no charge/discharge delta calculated). charge_entry = entity_registry.async_get_entity_id( "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" ) - assert charge_entry is None + assert charge_entry is not None + state = hass.states.get(charge_entry) + assert state is not None + assert state.state in {"0.0", STATE_UNKNOWN} - # 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) +@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 inventory batteries without a serial number 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) - # Storage sensors should now exist + # 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" ) - 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 + # No per-battery sensors because the battery has no serial. + bat_entries = [ + e + for e in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if "battery" in e.unique_id and e.unique_id != charge_entry + ] + assert all("BAT" not in entry.unique_id for entry in bat_entries) -@patch("homeassistant.components.solaredge.SolarEdge") -async def test_storage_service_not_created_when_inventory_has_no_batteries( - mock_solaredge: MagicMock, + +@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_solaredge_api: AsyncMock, mock_config_entry: MockConfigEntry, + solaredge_api: Mock, 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": []}} - ) + """Test storage service stays idle when inventory recovers but reports 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) + freezer.tick(INVENTORY_UPDATE_DELAY + timedelta(seconds=1)) 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" ) From 68cec7cb28d5cae666224a0fbe66d1f1a3843160 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 05:49:52 +0000 Subject: [PATCH 02/13] Apply prettier formatting to SolarEdge test fixtures --- .../solaredge/fixtures/energy_details.json | 10 ++--- .../solaredge/fixtures/inventory.json | 37 ++++++++++++++----- .../solaredge/fixtures/overview.json | 10 ++--- .../solaredge/fixtures/power_flow.json | 19 ++++++---- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/tests/components/solaredge/fixtures/energy_details.json b/tests/components/solaredge/fixtures/energy_details.json index b51fa2d301051c..6a66c810f3959d 100644 --- a/tests/components/solaredge/fixtures/energy_details.json +++ b/tests/components/solaredge/fixtures/energy_details.json @@ -5,23 +5,23 @@ "meters": [ { "type": "Production", - "values": [{"date": "2025-01-01 00:00:00", "value": 12345.0}] + "values": [{ "date": "2025-01-01 00:00:00", "value": 12345.0 }] }, { "type": "Consumption", - "values": [{"date": "2025-01-01 00:00:00", "value": 9876.0}] + "values": [{ "date": "2025-01-01 00:00:00", "value": 9876.0 }] }, { "type": "SelfConsumption", - "values": [{"date": "2025-01-01 00:00:00", "value": 5432.0}] + "values": [{ "date": "2025-01-01 00:00:00", "value": 5432.0 }] }, { "type": "FeedIn", - "values": [{"date": "2025-01-01 00:00:00", "value": 6913.0}] + "values": [{ "date": "2025-01-01 00:00:00", "value": 6913.0 }] }, { "type": "Purchased", - "values": [{"date": "2025-01-01 00:00:00", "value": 4444.0}] + "values": [{ "date": "2025-01-01 00:00:00", "value": 4444.0 }] } ] } diff --git a/tests/components/solaredge/fixtures/inventory.json b/tests/components/solaredge/fixtures/inventory.json index 3fa6a1731f3c28..971ec2e6ad5dab 100644 --- a/tests/components/solaredge/fixtures/inventory.json +++ b/tests/components/solaredge/fixtures/inventory.json @@ -1,20 +1,39 @@ { "Inventory": { "meters": [ - {"name": "Production meter", "manufacturer": "Acme", "model": "M1"}, - {"name": "Consumption meter", "manufacturer": "Acme", "model": "M1"} + { "name": "Production meter", "manufacturer": "Acme", "model": "M1" }, + { "name": "Consumption meter", "manufacturer": "Acme", "model": "M1" } ], "sensors": [], - "gateways": [ - {"name": "Gateway1", "serialNumber": "G-001"} - ], + "gateways": [{ "name": "Gateway1", "serialNumber": "G-001" }], "batteries": [ - {"name": "Battery1", "manufacturer": "Acme", "model": "B1", "SN": "BAT001", "nameplateCapacity": 9700.0} + { + "name": "Battery1", + "manufacturer": "Acme", + "model": "B1", + "SN": "BAT001", + "nameplateCapacity": 9700.0 + } ], "inverters": [ - {"name": "Inverter1", "manufacturer": "Acme", "model": "I1", "serialNumber": "INV-001"}, - {"name": "Inverter2", "manufacturer": "Acme", "model": "I1", "serialNumber": "INV-002"}, - {"name": "Inverter3", "manufacturer": "Acme", "model": "I1", "serialNumber": "INV-003"} + { + "name": "Inverter1", + "manufacturer": "Acme", + "model": "I1", + "serialNumber": "INV-001" + }, + { + "name": "Inverter2", + "manufacturer": "Acme", + "model": "I1", + "serialNumber": "INV-002" + }, + { + "name": "Inverter3", + "manufacturer": "Acme", + "model": "I1", + "serialNumber": "INV-003" + } ] } } diff --git a/tests/components/solaredge/fixtures/overview.json b/tests/components/solaredge/fixtures/overview.json index 1914f4c6f5533a..8e3c2ba0591c20 100644 --- a/tests/components/solaredge/fixtures/overview.json +++ b/tests/components/solaredge/fixtures/overview.json @@ -1,11 +1,11 @@ { "overview": { "lastUpdateTime": "2025-01-01 12:00:00", - "lifeTimeData": {"energy": 100000.0, "revenue": 10.0}, - "lastYearData": {"energy": 50000.0}, - "lastMonthData": {"energy": 10000.0}, - "lastDayData": {"energy": 250.0}, - "currentPower": {"power": 1234.5}, + "lifeTimeData": { "energy": 100000.0, "revenue": 10.0 }, + "lastYearData": { "energy": 50000.0 }, + "lastMonthData": { "energy": 10000.0 }, + "lastDayData": { "energy": 250.0 }, + "currentPower": { "power": 1234.5 }, "measuredBy": "INVERTER" } } diff --git a/tests/components/solaredge/fixtures/power_flow.json b/tests/components/solaredge/fixtures/power_flow.json index 2dee7032bb0a1b..22c12c9d18a4c4 100644 --- a/tests/components/solaredge/fixtures/power_flow.json +++ b/tests/components/solaredge/fixtures/power_flow.json @@ -3,13 +3,18 @@ "updateRefreshRate": 3, "unit": "W", "connections": [ - {"from": "GRID", "to": "Load"}, - {"from": "PV", "to": "Load"}, - {"from": "PV", "to": "STORAGE"} + { "from": "GRID", "to": "Load" }, + { "from": "PV", "to": "Load" }, + { "from": "PV", "to": "STORAGE" } ], - "GRID": {"status": "Active", "currentPower": 250.0}, - "LOAD": {"status": "Active", "currentPower": 1500.0}, - "PV": {"status": "Active", "currentPower": 1500.0}, - "STORAGE": {"status": "Charging", "currentPower": 250.0, "chargeLevel": 75, "critical": false} + "GRID": { "status": "Active", "currentPower": 250.0 }, + "LOAD": { "status": "Active", "currentPower": 1500.0 }, + "PV": { "status": "Active", "currentPower": 1500.0 }, + "STORAGE": { + "status": "Charging", + "currentPower": 250.0, + "chargeLevel": 75, + "critical": false + } } } From d8c92312e1f41d37a891fefadcc64b02bcc5f736 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 06:10:14 +0000 Subject: [PATCH 03/13] Fix vacuous assertion in test_inventory_battery_without_serial_skipped Address Copilot review: the previous filter compared `e.unique_id` against an `entity_id` value, so the result list was always empty and the `all(...)` assertion was vacuously true. Filter on the per-battery unique_id pattern (`_battery_`) and assert the list is empty directly. --- tests/components/solaredge/test_sensor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py index e299781ebc241c..e0fbf258fdc669 100644 --- a/tests/components/solaredge/test_sensor.py +++ b/tests/components/solaredge/test_sensor.py @@ -442,14 +442,15 @@ async def test_inventory_battery_without_serial_skipped( assert charge_entry is not None # No per-battery sensors because the battery has no serial. - bat_entries = [ + # 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 and e.unique_id != charge_entry + if "_battery_" in e.unique_id ] - assert all("BAT" not in entry.unique_id for entry in bat_entries) + assert per_battery_entries == [] @pytest.mark.usefixtures("entity_registry_enabled_by_default") From ef350a44ace82f740817c92634fa78b7657270f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 06:16:29 +0000 Subject: [PATCH 04/13] Restore defensive guards in SolarEdge sensor platform Revert the removal of two unreachable-but-defensive branches in sensor.py: - The `if DATA_API_CLIENT not in entry.runtime_data: return` early return in async_setup_entry guards against the platform being forwarded without an API client. - The `unique_id` property on SolarEdgeDetailsSensor that returns None when site_id is empty is restored as a property override. Coverage drops from 100% to 99% (sensor.py: 97%) but the source code remains identical to upstream. Coverage-only PRs should not change behaviour, even when the affected branches look unreachable. --- homeassistant/components/solaredge/sensor.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index e3f1a996ba783c..096b1eed70dc69 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -275,6 +275,10 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add an solarEdge entry.""" + # Add sensor entities only if API key is configured + if DATA_API_CLIENT not in entry.runtime_data: + return + api = entry.runtime_data[DATA_API_CLIENT] sensor_factory = SolarEdgeSensorFactory(hass, entry, entry.data[CONF_SITE_ID], api) @@ -476,15 +480,6 @@ def native_value(self) -> str | None: class SolarEdgeDetailsSensor(SolarEdgeSensorEntity): """Representation of an SolarEdge Monitoring API details sensor.""" - def __init__( - self, - description: SolarEdgeSensorEntityDescription, - data_service: SolarEdgeDataService, - ) -> None: - """Initialize the sensor.""" - super().__init__(description, data_service) - self._attr_unique_id = str(data_service.site_id) - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" @@ -495,6 +490,13 @@ def native_value(self) -> str | None: """Return the state of the sensor.""" return self.data_service.data.get(self.entity_description.json_key) + @property + def unique_id(self) -> str | None: + """Return a unique ID.""" + if not self.data_service.site_id: + return None + return str(self.data_service.site_id) + class SolarEdgeInventorySensor(SolarEdgeSensorEntity): """Representation of an SolarEdge Monitoring API inventory sensor.""" From ea2ebe43ef707e2d3ed54689570a4704ee939d10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 06:23:47 +0000 Subject: [PATCH 05/13] Address Copilot review on storage sensor tests Two follow-up review comments from Copilot: - test_storage_data_missing_keys_in_response only checked the charge aggregate sensor. The neighbouring test_storage_data_service_api_error checks both. Add the discharge sensor parity. - test_storage_service_deferred_after_inventory_failure reused the `charge_entry` variable for two assertions with opposite results (`is None` before recovery, `is not None` after). Inline the lookups so the two states are visually distinct. --- tests/components/solaredge/test_sensor.py | 30 ++++++++++++++++------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py index e0fbf258fdc669..1d2cd1cb976654 100644 --- a/tests/components/solaredge/test_sensor.py +++ b/tests/components/solaredge/test_sensor.py @@ -318,8 +318,13 @@ async def test_storage_data_missing_keys_in_response( 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") @@ -337,10 +342,13 @@ async def test_storage_service_deferred_after_inventory_failure( await setup_integration(hass, mock_config_entry) - charge_entry = entity_registry.async_get_entity_id( - "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + # 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 ) - assert charge_entry is None # Inventory recovers and reports a battery → storage sensors get created. solaredge_api.get_inventory.side_effect = None @@ -350,14 +358,18 @@ async def test_storage_service_deferred_after_inventory_failure( 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 ( + entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_charge_energy" + ) + is not None ) - discharge_entry = entity_registry.async_get_entity_id( - "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + assert ( + entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{SITE_ID}_storage_discharge_energy" + ) + is not None ) - assert charge_entry is not None - assert discharge_entry is not None @pytest.mark.parametrize( From 4509484bcfc6f23f648ed2ad812df5f281fea530 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 06:46:03 +0000 Subject: [PATCH 06/13] Tighten malformed-storage-response assertion per Copilot review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the loose `state.state in {"0.0", STATE_UNKNOWN}` check (which hid the fact that each parametrize case has a definite expected outcome) with an explicit `expected_charge_state` parameter. The empty batteries case asserts STATE_UNKNOWN (early return, aggregate never set); the other three cases assert "0.0" (loop short-circuits / single telemetry → totals stay at the initial 0.0). --- tests/components/solaredge/test_sensor.py | 69 +++++++++++++---------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py index 1d2cd1cb976654..dd0bf36b6e152e 100644 --- a/tests/components/solaredge/test_sensor.py +++ b/tests/components/solaredge/test_sensor.py @@ -373,33 +373,45 @@ async def test_storage_service_deferred_after_inventory_failure( @pytest.mark.parametrize( - "storage_response", + ("storage_response", "expected_charge_state"), [ - # Empty batteries list inside the storage data - {"storageData": {"batteries": []}}, - # Battery missing the serialNumber key — skipped by the data service - {"storageData": {"batteries": [{"telemetries": []}]}}, - # Battery with no telemetries — skipped after the serial check - {"storageData": {"batteries": [{"serialNumber": "BAT001", "telemetries": []}]}}, - # Battery with a single telemetry — falls into 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, - } - ], - } - ] - } - }, + # 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", @@ -416,21 +428,20 @@ async def test_storage_data_service_handles_malformed_responses( solaredge_api: Mock, entity_registry: er.EntityRegistry, storage_response: dict, + expected_charge_state: str, ) -> None: """Test storage data service tolerates batteries without serial / telemetries / single telemetry.""" solaredge_api.get_storage_data.return_value = storage_response await setup_integration(hass, mock_config_entry) - # Aggregate sensors are still created (inventory has a battery), but their - # state should be 0.0 (no charge/discharge delta calculated). 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 in {"0.0", STATE_UNKNOWN} + assert state.state == expected_charge_state @pytest.mark.usefixtures("entity_registry_enabled_by_default") From 32d465f98aca36ef84c63ee100f9afd4dfe245f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 15:36:45 +0000 Subject: [PATCH 07/13] Address joostlek review on PR #169178 - Move all sensor-state assertions from test_coordinator.py to test_sensor.py: tests that go through `setup_integration` and assert on `hass.states.get(...)` are sensor tests by intent, regardless of which coordinator branch they exercise. The data-service KeyError parametrize, the energy-details filter test, the power-flow sign-flip and zero-power tests, and the per-service "unknown when ..." tests all move over. test_coordinator.py keeps the test_solaredgeoverviewdataservice_energy_values_validity test that was already there and the modules-coordinator-specific tests that assert on statistics or config entry state. - Drop the spurious `+ timedelta(seconds=1)` in the two inventory-recovery tests; `freezer.tick(INVENTORY_UPDATE_DELAY)` is enough to fire the coordinator update. --- .../components/solaredge/test_coordinator.py | 186 +---------------- tests/components/solaredge/test_sensor.py | 195 +++++++++++++++++- 2 files changed, 193 insertions(+), 188 deletions(-) diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index ccaee6c7799a5f..7903c88bdfe478 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, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory @@ -25,7 +25,6 @@ CONF_NAME, CONF_PASSWORD, CONF_USERNAME, - STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -423,189 +422,6 @@ async def test_modules_coordinator_no_energy_data( assert not stats -@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"), - ], -) -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 when a data service refresh raises 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 - - -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 - - -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 - - -async def test_energy_details_filters_meters( - recorder_mock: Recorder, - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - solaredge_api: Mock, -) -> None: - """Test energy details data service skips meters without type/values and unsupported types.""" - 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 - - -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 - - -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 reports for storage discharge.""" - 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 - - -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" - - async def test_modules_coordinator_api_failure( recorder_mock: Recorder, hass: HomeAssistant, diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py index dd0bf36b6e152e..39a65b3e296271 100644 --- a/tests/components/solaredge/test_sensor.py +++ b/tests/components/solaredge/test_sensor.py @@ -1,6 +1,5 @@ """Tests for the SolarEdge sensor platform.""" -from datetime import timedelta from unittest.mock import Mock, patch from aiohttp import ClientError @@ -136,6 +135,196 @@ async def test_no_sensors_without_api_key( 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 when a data service refresh raises 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 data service skips meters without type/values and unsupported types.""" + 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 reports for storage discharge.""" + 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, @@ -354,7 +543,7 @@ async def test_storage_service_deferred_after_inventory_failure( solaredge_api.get_inventory.side_effect = None solaredge_api.get_inventory.return_value = valid_inventory - freezer.tick(INVENTORY_UPDATE_DELAY + timedelta(seconds=1)) + freezer.tick(INVENTORY_UPDATE_DELAY) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) @@ -494,7 +683,7 @@ async def test_storage_service_not_retried_after_recovery_with_no_batteries( solaredge_api.get_inventory.side_effect = None solaredge_api.get_inventory.return_value = {"Inventory": {"batteries": []}} - freezer.tick(INVENTORY_UPDATE_DELAY + timedelta(seconds=1)) + freezer.tick(INVENTORY_UPDATE_DELAY) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) From d2fd86d1f25f6a98770ca0c6f7e1ffce07bf899c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 16:53:58 +0000 Subject: [PATCH 08/13] Fix solaredge_web_api parameter type hint per Copilot review The conftest fixture returns an AsyncMock; the test_no_sensors_without_api_key parameter was typed as Mock, inconsistently with every other test in the directory. --- tests/components/solaredge/test_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py index 39a65b3e296271..b1f6078db49674 100644 --- a/tests/components/solaredge/test_sensor.py +++ b/tests/components/solaredge/test_sensor.py @@ -1,6 +1,6 @@ """Tests for the SolarEdge sensor platform.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory @@ -123,7 +123,7 @@ async def test_no_sensors_without_api_key( recorder_mock: Recorder, hass: HomeAssistant, mock_config_entry_web_login: MockConfigEntry, - solaredge_web_api: Mock, + solaredge_web_api: AsyncMock, entity_registry: er.EntityRegistry, ) -> None: """Test no sensors are created when only web login auth is configured.""" From 61e3e4e968f8b29021882e1ffdfd1c995a706efd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 21:28:43 +0000 Subject: [PATCH 09/13] Address joostlek review on test fixtures - Parametrize the two API-key setup failure tests in test_init.py on the get_details response and the expected ConfigEntryState, and use the shared mock_config_entry fixture. - Replace the manual MockConfigEntry construction in test_modules_coordinator_api_failure with the existing mock_config_entry_web_login fixture from conftest. --- .../components/solaredge/test_coordinator.py | 9 ++-- tests/components/solaredge/test_init.py | 52 ++++++++----------- 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 7903c88bdfe478..c385e5d28d12be 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -425,15 +425,12 @@ async def test_modules_coordinator_no_energy_data( async def test_modules_coordinator_api_failure( recorder_mock: Recorder, hass: HomeAssistant, + mock_config_entry_web_login: MockConfigEntry, solaredge_web_api: AsyncMock, ) -> None: """Test the modules coordinator surfaces API failures via the config entry state.""" solaredge_web_api.async_get_equipment.side_effect = ClientError("boom") - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_SITE_ID: SITE_ID, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - await setup_integration(hass, entry) + await setup_integration(hass, mock_config_entry_web_login) - assert entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry_web_login.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/solaredge/test_init.py b/tests/components/solaredge/test_init.py index 15e60db69f60e5..6901a46ba5208e 100644 --- a/tests/components/solaredge/test_init.py +++ b/tests/components/solaredge/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError +import pytest from homeassistant.components.recorder import Recorder from homeassistant.components.solaredge.const import CONF_SITE_ID, DOMAIN @@ -10,6 +11,7 @@ from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from . import setup_integration from .conftest import API_KEY, PASSWORD, SITE_ID, USERNAME from tests.common import MockConfigEntry @@ -158,35 +160,27 @@ async def test_web_login_config_not_ready( assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_missing_details_key( - recorder_mock: Recorder, hass: HomeAssistant, solaredge_api: Mock -) -> None: - """Test setup raises ConfigEntryNotReady when API response has no 'details' key.""" - solaredge_api.get_details.return_value = {} - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_site_not_active( - recorder_mock: Recorder, hass: HomeAssistant, solaredge_api: Mock +@pytest.mark.parametrize( + ("get_details_response", "expected_state"), + [ + # Missing 'details' key → ConfigEntryNotReady → SETUP_RETRY + ({}, ConfigEntryState.SETUP_RETRY), + # Site status is not 'active' → setup returns False → SETUP_ERROR + ({"details": {"status": "Disabled"}}, ConfigEntryState.SETUP_ERROR), + ], + ids=["missing_details_key", "site_not_active"], +) +async def test_setup_api_key_failure( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + solaredge_api: Mock, + get_details_response: dict, + expected_state: ConfigEntryState, ) -> None: - """Test setup fails when the site status is not 'active'.""" - solaredge_api.get_details.return_value = {"details": {"status": "Disabled"}} - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, - ) - entry.add_to_hass(hass) + """Test the API-key setup failure paths in async_setup_entry.""" + solaredge_api.get_details.return_value = get_details_response - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) - assert entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is expected_state From f756392b6a41efaebcc69adf8784fbf8a5619549 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 12 May 2026 22:41:03 -0700 Subject: [PATCH 10/13] Add nest.set_fan_timer service action (#170367) --- homeassistant/components/nest/__init__.py | 2 + homeassistant/components/nest/climate.py | 27 ++- homeassistant/components/nest/icons.json | 7 + homeassistant/components/nest/services.py | 30 +++ homeassistant/components/nest/services.yaml | 12 ++ homeassistant/components/nest/strings.json | 12 ++ tests/components/nest/test_climate.py | 192 +++++++++++++++++++- 7 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/nest/icons.json create mode 100644 homeassistant/components/nest/services.py create mode 100644 homeassistant/components/nest/services.yaml diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 549dc1b234184c..1ddc7654d62b41 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -71,6 +71,7 @@ async_get_media_source_devices, async_get_transcoder, ) +from .services import async_setup_services from .types import DevicesAddedListener, NestConfigEntry, NestData _LOGGER = logging.getLogger(__name__) @@ -115,6 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Nest components with dispatch between old/new flows.""" hass.http.register_view(NestEventMediaView(hass)) hass.http.register_view(NestEventMediaThumbnailView(hass)) + async_setup_services(hass) return True diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index b2ca81563f7f2f..ea4073f59685e1 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -1,5 +1,6 @@ """Support for Google Nest SDM climate devices.""" +from datetime import timedelta from typing import Any, cast from google_nest_sdm.device import Device @@ -67,7 +68,7 @@ FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} FAN_INV_MODES = list(FAN_INV_MODE_MAP) -MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API +MAX_FAN_DURATION = 43200 # 12 hours is the max in the SDM API MIN_TEMP = 10 MAX_TEMP = 32 MIN_TEMP_RANGE = 1.66667 @@ -344,3 +345,27 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: raise HomeAssistantError( f"Error setting {self.entity_id} fan mode to {fan_mode}: {err}" ) from err + + async def async_set_fan_timer(self, duration: timedelta) -> None: + """Set a short term fan timer.""" + if not self.supported_features & ClimateEntityFeature.FAN_MODE: + raise HomeAssistantError(f"Entity {self.entity_id} does not support fan") + + if self.hvac_mode == HVACMode.OFF: + raise HomeAssistantError( + f"Cannot turn on fan for {self.entity_id}, please set an HVAC mode (e.g. heat/cool) first" + ) + + seconds = int(duration.total_seconds()) + if seconds <= 0 or seconds > MAX_FAN_DURATION: + raise ValueError( + f"Duration {seconds} for {self.entity_id} must be between 1 and {MAX_FAN_DURATION} seconds" + ) + + trait = self._device.traits[FanTrait.NAME] + try: + await trait.set_timer(FAN_INV_MODE_MAP[FAN_ON], duration=seconds) + except ApiException as err: + raise HomeAssistantError( + f"Error setting {self.entity_id} fan timer: {err}" + ) from err diff --git a/homeassistant/components/nest/icons.json b/homeassistant/components/nest/icons.json new file mode 100644 index 00000000000000..1147328d12a4a6 --- /dev/null +++ b/homeassistant/components/nest/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "set_fan_timer": { + "service": "mdi:fan-clock" + } + } +} diff --git a/homeassistant/components/nest/services.py b/homeassistant/components/nest/services.py new file mode 100644 index 00000000000000..0cb97f5a0a541d --- /dev/null +++ b/homeassistant/components/nest/services.py @@ -0,0 +1,30 @@ +"""Define services for the Nest integration.""" + +import voluptuous as vol + +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + ClimateEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN + +SERVICE_SET_FAN_TIMER = "set_fan_timer" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register services for the Nest integration.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_FAN_TIMER, + entity_domain=CLIMATE_DOMAIN, + schema={ + vol.Required("duration"): cv.time_period, + }, + func="async_set_fan_timer", + required_features=[ClimateEntityFeature.FAN_MODE], + ) diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml new file mode 100644 index 00000000000000..820ed5e1779230 --- /dev/null +++ b/homeassistant/components/nest/services.yaml @@ -0,0 +1,12 @@ +set_fan_timer: + target: + entity: + domain: climate + integration: nest + supported_features: + - climate.ClimateEntityFeature.FAN_MODE + fields: + duration: + required: true + selector: + duration: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index aa4490d03f92c0..6ae0d6622b543e 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -163,5 +163,17 @@ "create_new_topic": "Create new topic" } } + }, + "services": { + "set_fan_timer": { + "description": "Sets the fan to run for a specific duration.", + "fields": { + "duration": { + "description": "The duration the fan should run for.", + "name": "Duration" + } + }, + "name": "Set fan timer" + } } } diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index 76a9a52f2de248..e45fc5752d7224 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -39,11 +39,16 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceNotSupported, + ServiceValidationError, +) from .common import ( DEVICE_COMMAND, DEVICE_ID, + DOMAIN, CreateDevice, PlatformSetup, create_nest_event, @@ -1085,6 +1090,191 @@ async def test_thermostat_set_fan( } +async def test_set_fan_timer( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: + """Test the set_fan_timer service.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + }, + "sdm.devices.traits.ThermostatHvac": {"status": "HEATING"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "OFF"], + "mode": "HEAT", + }, + "sdm.devices.traits.TemperatureTrait": {"ambientTemperatureCelsius": 20.0}, + } + ) + await setup_platform() + + # Call the set_fan_timer service + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"minutes": 15}, + }, + blocking=True, + ) + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": { + "duration": "900s", + "timerMode": "ON", + }, + } + + +async def test_set_fan_timer_hvac_off( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: + """Test the set_fan_timer service when HVAC is off.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + }, + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "OFF"], + "mode": "OFF", + }, + } + ) + await setup_platform() + + with pytest.raises(HomeAssistantError, match="Cannot turn on fan"): + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"minutes": 15}, + }, + blocking=True, + ) + + +async def test_set_fan_timer_no_fan( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: + """Test the set_fan_timer service when device does not support fan.""" + create_device.create( + { + "sdm.devices.traits.ThermostatHvac": {"status": "HEATING"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "OFF"], + "mode": "HEAT", + }, + } + ) + await setup_platform() + + with pytest.raises(ServiceNotSupported, match="does not support action"): + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"minutes": 15}, + }, + blocking=True, + ) + + +async def test_set_fan_timer_api_error( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: + """Test the set_fan_timer service with an API error.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + }, + "sdm.devices.traits.ThermostatHvac": {"status": "HEATING"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "OFF"], + "mode": "HEAT", + }, + } + ) + await setup_platform() + + auth.responses = [aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)] + with pytest.raises(HomeAssistantError, match="Error setting .* fan timer"): + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"minutes": 15}, + }, + blocking=True, + ) + + +async def test_set_fan_timer_invalid_duration( + hass: HomeAssistant, + setup_platform: PlatformSetup, + create_device: CreateDevice, +) -> None: + """Test the set_fan_timer service with invalid durations.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + }, + "sdm.devices.traits.ThermostatHvac": {"status": "HEATING"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "OFF"], + "mode": "HEAT", + }, + } + ) + await setup_platform() + + # Duration too long (over 12 hours) + with pytest.raises(ValueError, match="must be between 1 and 43200 seconds"): + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"hours": 16}, + }, + blocking=True, + ) + + # Duration zero + with pytest.raises(ValueError, match="must be between 1 and 43200 seconds"): + await hass.services.async_call( + DOMAIN, + "set_fan_timer", + { + "entity_id": "climate.my_thermostat", + "duration": {"seconds": 0}, + }, + blocking=True, + ) + + async def test_thermostat_set_fan_when_off( hass: HomeAssistant, setup_platform: PlatformSetup, From 8b257cdd6cb337920fafa77512acc308745155f0 Mon Sep 17 00:00:00 2001 From: Jeef Date: Tue, 12 May 2026 23:53:23 -0600 Subject: [PATCH 11/13] Fix WeatherFlow Cloud empty observations (#170440) --- .../components/weatherflow_cloud/sensor.py | 19 ++++++++++-- .../weatherflow_cloud/test_sensor.py | 31 ++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py index be4fd2ee9a2249..51c5839b41507d 100644 --- a/homeassistant/components/weatherflow_cloud/sensor.py +++ b/homeassistant/components/weatherflow_cloud/sensor.py @@ -485,9 +485,22 @@ class WeatherFlowCloudSensorREST(WeatherFlowSensorBase): coordinator: WeatherFlowCloudUpdateCoordinatorREST + @property + def _observation(self) -> Observation | None: + """Return the current station observation.""" + observations = self.coordinator.data[self.station_id].observation.obs + if not observations: + return None + return observations[0] + + @property + def available(self) -> bool: + """Get if available.""" + return super().available and self._observation is not None + @property def native_value(self) -> StateType | datetime: """Return the native value.""" - return self.entity_description.value_fn( - self.coordinator.data[self.station_id].observation.obs[0] - ) + if (observation := self._observation) is None: + return None + return self.entity_description.value_fn(observation) diff --git a/tests/components/weatherflow_cloud/test_sensor.py b/tests/components/weatherflow_cloud/test_sensor.py index 0d4f63cf03e748..62c1779b667562 100644 --- a/tests/components/weatherflow_cloud/test_sensor.py +++ b/tests/components/weatherflow_cloud/test_sensor.py @@ -1,5 +1,6 @@ """Tests for the WeatherFlow Cloud sensor platform.""" +from dataclasses import replace from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch @@ -17,7 +18,7 @@ WeatherFlowWebsocketSensorObservation, WeatherFlowWebsocketSensorWind, ) -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -90,6 +91,34 @@ async def test_all_entities_with_lightning_error( ) +async def test_rest_sensor_unavailable_with_empty_observation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test REST sensors are unavailable when no current observation is returned.""" + with patch( + "homeassistant.components.weatherflow_cloud.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.my_home_station_temperature").state == "10.5" + + all_data = await mock_rest_api.get_all_data() + all_data[24432].observation = replace(all_data[24432].observation, obs=[]) + mock_rest_api.get_all_data.return_value = all_data + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.my_home_station_temperature").state == STATE_UNAVAILABLE + ) + + async def test_websocket_sensor_observation( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 2150c64d719a2a1c4be0eac2a059e9d6db3446b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 06:37:34 +0000 Subject: [PATCH 12/13] Cover all overview sensors in the API-error test Address Copilot review: the test name claims to check overview sensors (plural) but only asserted on lifetime_energy. Add the entity_registry_enabled_by_default marker and loop over all five overview-derived sensors so the disabled-by-default ones (energy_this_year/month/today) are actually exercised. --- tests/components/solaredge/test_sensor.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/components/solaredge/test_sensor.py b/tests/components/solaredge/test_sensor.py index b1f6078db49674..2e1527f6ba5ab7 100644 --- a/tests/components/solaredge/test_sensor.py +++ b/tests/components/solaredge/test_sensor.py @@ -80,6 +80,7 @@ async def test_all_entities( 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, @@ -91,9 +92,16 @@ async def test_overview_sensors_unavailable_on_api_error( await setup_integration(hass, mock_config_entry) - state = hass.states.get("sensor.solaredge_lifetime_energy") - assert state is not None - assert state.state == STATE_UNAVAILABLE + 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") From e2ae9c1b95676b49f6ee461d1f16dd8ebb1584f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Wed, 13 May 2026 08:55:04 +0200 Subject: [PATCH 13/13] Bump pywmspro to 0.3.4 (#170454) --- homeassistant/components/wmspro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index 5faf2c4ed79453..ae83d79a736178 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.3.3"] + "requirements": ["pywmspro==0.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9768b7302b603c..e582e5fba0b81f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2799,7 +2799,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.3 +pywmspro==0.3.4 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e22bcda28798ba..e557361ee3a0c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2398,7 +2398,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.3.3 +pywmspro==0.3.4 # homeassistant.components.ws66i pyws66i==1.1