diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index c4d764f4c71..f8dcb4f4a9c 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -3,20 +3,28 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from datetime import datetime, timedelta import logging from typing import Final, TypeVar from pyfronius import Fronius, FroniusError -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval -from .const import DOMAIN, SOLAR_NET_ID_SYSTEM, FroniusDeviceInfo +from .const import ( + DOMAIN, + SOLAR_NET_ID_SYSTEM, + SOLAR_NET_RESCAN_TIMER, + FroniusDeviceInfo, +) from .coordinator import ( FroniusCoordinatorBase, FroniusInverterUpdateCoordinator, @@ -26,6 +34,7 @@ from .coordinator import ( FroniusPowerFlowUpdateCoordinator, FroniusStorageUpdateCoordinator, ) +from .sensor import InverterSensor _LOGGER: Final = logging.getLogger(__name__) PLATFORMS: Final = [Platform.SENSOR] @@ -67,6 +76,7 @@ class FroniusSolarNet: self.cleanup_callbacks: list[Callable[[], None]] = [] self.config_entry = entry self.coordinator_lock = asyncio.Lock() + self.sensor_async_add_entities: AddEntitiesCallback | None = None self.fronius = fronius self.host: str = entry.data[CONF_HOST] # entry.unique_id is either logger uid or first inverter uid if no logger available @@ -95,17 +105,7 @@ class FroniusSolarNet: # _create_solar_net_device uses data from self.logger_coordinator when available self.system_device_info = await self._create_solar_net_device() - _inverter_infos = await self._get_inverter_infos() - for inverter_info in _inverter_infos: - coordinator = FroniusInverterUpdateCoordinator( - hass=self.hass, - solar_net=self, - logger=_LOGGER, - name=f"{DOMAIN}_inverter_{inverter_info.solar_net_id}_{self.host}", - inverter_info=inverter_info, - ) - await coordinator.async_config_entry_first_refresh() - self.inverter_coordinators.append(coordinator) + await self._init_devices_inverter() self.meter_coordinator = await self._init_optional_coordinator( FroniusMeterUpdateCoordinator( @@ -143,6 +143,15 @@ class FroniusSolarNet: ) ) + # Setup periodic re-scan + self.cleanup_callbacks.append( + async_track_time_interval( + self.hass, + self._init_devices_inverter, + timedelta(minutes=SOLAR_NET_RESCAN_TIMER), + ) + ) + async def _create_solar_net_device(self) -> DeviceInfo: """Create a device for the Fronius SolarNet system.""" solar_net_device: DeviceInfo = DeviceInfo( @@ -168,14 +177,57 @@ class FroniusSolarNet: ) return solar_net_device + async def _init_devices_inverter(self, _now: datetime | None = None) -> None: + """Get available inverters and set up coordinators for new found devices.""" + _inverter_infos = await self._get_inverter_infos() + + _LOGGER.debug("Processing inverters for: %s", _inverter_infos) + for _inverter_info in _inverter_infos: + _inverter_name = ( + f"{DOMAIN}_inverter_{_inverter_info.solar_net_id}_{self.host}" + ) + + # Add found inverter only not already existing + if _inverter_info.solar_net_id in [ + inv.inverter_info.solar_net_id for inv in self.inverter_coordinators + ]: + continue + + _coordinator = FroniusInverterUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=_inverter_name, + inverter_info=_inverter_info, + ) + await _coordinator.async_config_entry_first_refresh() + self.inverter_coordinators.append(_coordinator) + + # Only for re-scans. Initial setup adds entities through sensor.async_setup_entry + if self.sensor_async_add_entities is not None: + _coordinator.add_entities_for_seen_keys( + self.sensor_async_add_entities, InverterSensor + ) + + _LOGGER.debug( + "New inverter added (UID: %s)", + _inverter_info.unique_id, + ) + async def _get_inverter_infos(self) -> list[FroniusDeviceInfo]: """Get information about the inverters in the SolarNet system.""" + inverter_infos: list[FroniusDeviceInfo] = [] + try: _inverter_info = await self.fronius.inverter_info() except FroniusError as err: + if self.config_entry.state == ConfigEntryState.LOADED: + # During a re-scan we will attempt again as per schedule. + _LOGGER.debug("Re-scan failed for %s", self.host) + return inverter_infos + raise ConfigEntryNotReady from err - inverter_infos: list[FroniusDeviceInfo] = [] for inverter in _inverter_info["inverters"]: solar_net_id = inverter["device_id"]["value"] unique_id = inverter["unique_id"]["value"] @@ -195,6 +247,12 @@ class FroniusSolarNet: unique_id=unique_id, ) ) + _LOGGER.debug( + "Inverter found at %s (Device ID: %s, UID: %s)", + self.host, + solar_net_id, + unique_id, + ) return inverter_infos @staticmethod diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py index de3e0cc9563..042773472c5 100644 --- a/homeassistant/components/fronius/const.py +++ b/homeassistant/components/fronius/const.py @@ -8,6 +8,7 @@ DOMAIN: Final = "fronius" SolarNetId = str SOLAR_NET_ID_POWER_FLOW: SolarNetId = "power_flow" SOLAR_NET_ID_SYSTEM: SolarNetId = "system" +SOLAR_NET_RESCAN_TIMER: Final = 60 class FroniusConfigEntryData(TypedDict): diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 4e706db032f..d701d0d1860 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -53,6 +53,8 @@ async def async_setup_entry( ) -> None: """Set up Fronius sensor entities based on a config entry.""" solar_net: FroniusSolarNet = hass.data[DOMAIN][config_entry.entry_id] + solar_net.sensor_async_add_entities = async_add_entities + for inverter_coordinator in solar_net.inverter_coordinators: inverter_coordinator.add_entities_for_seen_keys( async_add_entities, InverterSensor diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index bd70604398d..4d11291508b 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -59,7 +59,7 @@ def mock_responses( ) aioclient_mock.get( f"{host}/solar_api/v1/GetInverterInfo.cgi", - text=load_fixture(f"{fixture_set}/GetInverterInfo.json", "fronius"), + text=load_fixture(f"{fixture_set}/GetInverterInfo{_night}.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetLoggerInfo.cgi", diff --git a/tests/components/fronius/fixtures/igplus_v2/GetAPIVersion.json b/tests/components/fronius/fixtures/igplus_v2/GetAPIVersion.json new file mode 100644 index 00000000000..28b2077691c --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetAPIVersion.json @@ -0,0 +1,5 @@ +{ + "APIVersion": 1, + "BaseURL": "/solar_api/v1/", + "CompatibilityRange": "1.5-18" +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo.json b/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo.json new file mode 100644 index 00000000000..844fcff89e4 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo.json @@ -0,0 +1,24 @@ +{ + "Body": { + "Data": { + "1": { + "CustomName": "IG Plus 70 V-2", + "DT": 174, + "ErrorCode": 0, + "PVPower": 6500, + "Show": 1, + "StatusCode": 7, + "UniqueID": "203200" + } + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:19:20+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo_night.json b/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo_night.json new file mode 100644 index 00000000000..e65784e7971 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo_night.json @@ -0,0 +1,14 @@ +{ + "Body": { + "Data": {} + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-06-27T21:48:52+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1.json b/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1.json new file mode 100644 index 00000000000..150ea901a0c --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1.json @@ -0,0 +1,64 @@ +{ + "Body": { + "Data": { + "DAY_ENERGY": { + "Unit": "Wh", + "Value": 42000 + }, + "DeviceStatus": { + "ErrorCode": 0, + "LEDColor": 2, + "LEDState": 0, + "MgmtTimerRemainingTime": -1, + "StateToReset": false, + "StatusCode": 7 + }, + "FAC": { + "Unit": "Hz", + "Value": 49.960000000000001 + }, + "IAC": { + "Unit": "A", + "Value": 9.0299999999999994 + }, + "IDC": { + "Unit": "A", + "Value": 6.46 + }, + "PAC": { + "Unit": "W", + "Value": 2096 + }, + "TOTAL_ENERGY": { + "Unit": "Wh", + "Value": 81809000 + }, + "UAC": { + "Unit": "V", + "Value": 232 + }, + "UDC": { + "Unit": "V", + "Value": 345 + }, + "YEAR_ENERGY": { + "Unit": "Wh", + "Value": 4927000 + } + } + }, + "Head": { + "RequestArguments": { + "DataCollection": "CommonInverterData", + "DeviceClass": "Inverter", + "DeviceId": "1", + "Scope": "Device" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:21:42+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1_night.json b/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1_night.json new file mode 100644 index 00000000000..e65784e7971 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1_night.json @@ -0,0 +1,14 @@ +{ + "Body": { + "Data": {} + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-06-27T21:48:52+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetLoggerInfo.json b/tests/components/fronius/fixtures/igplus_v2/GetLoggerInfo.json new file mode 100644 index 00000000000..0ebeb823def --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetLoggerInfo.json @@ -0,0 +1,29 @@ +{ + "Body": { + "LoggerInfo": { + "CO2Factor": 0.52999997138977051, + "CO2Unit": "kg", + "CashCurrency": "EUR", + "CashFactor": 0.07700000643730164, + "DefaultLanguage": "en", + "DeliveryFactor": 0.25, + "HWVersion": "2.4D", + "PlatformID": "wilma", + "ProductID": "fronius-datamanager-card", + "SWVersion": "3.26.1-3", + "TimezoneLocation": "Berlin", + "TimezoneName": "CEST", + "UTCOffset": 7200, + "UniqueID": "123.4567890" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:23:22+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetMeterRealtimeData.json b/tests/components/fronius/fixtures/igplus_v2/GetMeterRealtimeData.json new file mode 100644 index 00000000000..30de1a1fa98 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetMeterRealtimeData.json @@ -0,0 +1,17 @@ +{ + "Body": { + "Data": {} + }, + "Head": { + "RequestArguments": { + "DeviceClass": "Meter", + "Scope": "System" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:28:05+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetOhmPilotRealtimeData.json b/tests/components/fronius/fixtures/igplus_v2/GetOhmPilotRealtimeData.json new file mode 100644 index 00000000000..e77b751db3b --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetOhmPilotRealtimeData.json @@ -0,0 +1,17 @@ +{ + "Body": { + "Data": {} + }, + "Head": { + "RequestArguments": { + "DeviceClass": "OhmPilot", + "Scope": "System" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:29:16+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData.json b/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData.json new file mode 100644 index 00000000000..a8ae2fc6d86 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData.json @@ -0,0 +1,38 @@ +{ + "Body": { + "Data": { + "Inverters": { + "1": { + "DT": 174, + "E_Day": 43000, + "E_Total": 1230000, + "E_Year": 12345, + "P": 2241 + } + }, + "Site": { + "E_Day": 43000, + "E_Total": 1230000, + "E_Year": 12345, + "Meter_Location": "unknown", + "Mode": "produce-only", + "P_Akku": null, + "P_Grid": null, + "P_Load": null, + "P_PV": 2241, + "rel_Autonomy": null, + "rel_SelfConsumption": null + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:29:55+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData_night.json b/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData_night.json new file mode 100644 index 00000000000..1da28803195 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData_night.json @@ -0,0 +1,32 @@ +{ + "Body": { + "Data": { + "Inverters": {}, + "Site": { + "E_Day": null, + "E_Total": null, + "E_Year": null, + "Meter_Location": "unknown", + "Mode": "produce-only", + "P_Akku": null, + "P_Grid": null, + "P_Load": null, + "P_PV": null, + "rel_Autonomy": null, + "rel_SelfConsumption": null + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": { + "humanreadable": "false" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-13T22:04:44+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetStorageRealtimeData.json b/tests/components/fronius/fixtures/igplus_v2/GetStorageRealtimeData.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetStorageRealtimeData.json @@ -0,0 +1 @@ +{} diff --git a/tests/components/fronius/fixtures/symo/GetInverterInfo_night.json b/tests/components/fronius/fixtures/symo/GetInverterInfo_night.json new file mode 100644 index 00000000000..5b2676c3a3f --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetInverterInfo_night.json @@ -0,0 +1,24 @@ +{ + "Body": { + "Data": { + "1": { + "CustomName": "Symo 20", + "DT": 121, + "ErrorCode": 0, + "PVPower": 23100, + "Show": 1, + "StatusCode": 7, + "UniqueID": "1234567" + } + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-10-07T13:41:00+02:00" + } +} diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py index 0e8b405da44..d46c60c3cb3 100644 --- a/tests/components/fronius/test_init.py +++ b/tests/components/fronius/test_init.py @@ -1,14 +1,18 @@ """Test the Fronius integration.""" +from datetime import timedelta from unittest.mock import patch from pyfronius import FroniusError -from homeassistant.components.fronius.const import DOMAIN +from homeassistant.components.fronius.const import DOMAIN, SOLAR_NET_RESCAN_TIMER from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.util import dt as dt_util from . import mock_responses, setup_fronius_integration +from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -53,3 +57,82 @@ async def test_inverter_error( ): config_entry = await setup_fronius_integration(hass) assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_inverter_night_rescan( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test dynamic adding of an inverter discovered automatically after a Home Assistant reboot during the night.""" + mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) + config_entry = await setup_fronius_integration(hass, is_logger=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Only expect logger during the night + fronius_entries = hass.config_entries.async_entries(DOMAIN) + assert len(fronius_entries) == 1 + + # Switch to daytime + mock_responses(aioclient_mock, fixture_set="igplus_v2", night=False) + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER) + ) + await hass.async_block_till_done() + + # We expect our inverter to be present now + device_registry = dr.async_get(hass) + inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "203200")}) + assert inverter_1.manufacturer == "Fronius" + + # After another re-scan we still only expect this inverter + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER * 2) + ) + await hass.async_block_till_done() + inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "203200")}) + assert inverter_1.manufacturer == "Fronius" + + +async def test_inverter_rescan_interruption( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test interruption of re-scan during runtime to process further.""" + mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) + config_entry = await setup_fronius_integration(hass, is_logger=True) + assert config_entry.state is ConfigEntryState.LOADED + device_registry = dr.async_get(hass) + # Expect 1 devices during the night, logger + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 1 + ) + + with patch( + "pyfronius.Fronius.inverter_info", + side_effect=FroniusError, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER) + ) + await hass.async_block_till_done() + + # No increase of devices expected because of a FroniusError + assert ( + len( + dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + ) + == 1 + ) + + # Next re-scan will pick up the new inverter. Expect 2 devices now. + mock_responses(aioclient_mock, fixture_set="igplus_v2", night=False) + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER * 2) + ) + await hass.async_block_till_done() + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 2 + )