Periodically re-scan for Fronius inverters that were offline while setup (#96538)

pull/96739/head
Björn 2023-07-17 10:16:28 +02:00 committed by GitHub
parent 65ebb6a74f
commit e29b6408f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 439 additions and 16 deletions

View File

@ -3,20 +3,28 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime, timedelta
import logging import logging
from typing import Final, TypeVar from typing import Final, TypeVar
from pyfronius import Fronius, FroniusError 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.const import ATTR_MODEL, ATTR_SW_VERSION, CONF_HOST, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import DeviceInfo 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 ( from .coordinator import (
FroniusCoordinatorBase, FroniusCoordinatorBase,
FroniusInverterUpdateCoordinator, FroniusInverterUpdateCoordinator,
@ -26,6 +34,7 @@ from .coordinator import (
FroniusPowerFlowUpdateCoordinator, FroniusPowerFlowUpdateCoordinator,
FroniusStorageUpdateCoordinator, FroniusStorageUpdateCoordinator,
) )
from .sensor import InverterSensor
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
PLATFORMS: Final = [Platform.SENSOR] PLATFORMS: Final = [Platform.SENSOR]
@ -67,6 +76,7 @@ class FroniusSolarNet:
self.cleanup_callbacks: list[Callable[[], None]] = [] self.cleanup_callbacks: list[Callable[[], None]] = []
self.config_entry = entry self.config_entry = entry
self.coordinator_lock = asyncio.Lock() self.coordinator_lock = asyncio.Lock()
self.sensor_async_add_entities: AddEntitiesCallback | None = None
self.fronius = fronius self.fronius = fronius
self.host: str = entry.data[CONF_HOST] self.host: str = entry.data[CONF_HOST]
# entry.unique_id is either logger uid or first inverter uid if no logger available # 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 # _create_solar_net_device uses data from self.logger_coordinator when available
self.system_device_info = await self._create_solar_net_device() self.system_device_info = await self._create_solar_net_device()
_inverter_infos = await self._get_inverter_infos() await self._init_devices_inverter()
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)
self.meter_coordinator = await self._init_optional_coordinator( self.meter_coordinator = await self._init_optional_coordinator(
FroniusMeterUpdateCoordinator( 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: async def _create_solar_net_device(self) -> DeviceInfo:
"""Create a device for the Fronius SolarNet system.""" """Create a device for the Fronius SolarNet system."""
solar_net_device: DeviceInfo = DeviceInfo( solar_net_device: DeviceInfo = DeviceInfo(
@ -168,14 +177,57 @@ class FroniusSolarNet:
) )
return solar_net_device 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]: async def _get_inverter_infos(self) -> list[FroniusDeviceInfo]:
"""Get information about the inverters in the SolarNet system.""" """Get information about the inverters in the SolarNet system."""
inverter_infos: list[FroniusDeviceInfo] = []
try: try:
_inverter_info = await self.fronius.inverter_info() _inverter_info = await self.fronius.inverter_info()
except FroniusError as err: 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 raise ConfigEntryNotReady from err
inverter_infos: list[FroniusDeviceInfo] = []
for inverter in _inverter_info["inverters"]: for inverter in _inverter_info["inverters"]:
solar_net_id = inverter["device_id"]["value"] solar_net_id = inverter["device_id"]["value"]
unique_id = inverter["unique_id"]["value"] unique_id = inverter["unique_id"]["value"]
@ -195,6 +247,12 @@ class FroniusSolarNet:
unique_id=unique_id, 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 return inverter_infos
@staticmethod @staticmethod

View File

@ -8,6 +8,7 @@ DOMAIN: Final = "fronius"
SolarNetId = str SolarNetId = str
SOLAR_NET_ID_POWER_FLOW: SolarNetId = "power_flow" SOLAR_NET_ID_POWER_FLOW: SolarNetId = "power_flow"
SOLAR_NET_ID_SYSTEM: SolarNetId = "system" SOLAR_NET_ID_SYSTEM: SolarNetId = "system"
SOLAR_NET_RESCAN_TIMER: Final = 60
class FroniusConfigEntryData(TypedDict): class FroniusConfigEntryData(TypedDict):

View File

@ -53,6 +53,8 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Fronius sensor entities based on a config entry.""" """Set up Fronius sensor entities based on a config entry."""
solar_net: FroniusSolarNet = hass.data[DOMAIN][config_entry.entry_id] 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: for inverter_coordinator in solar_net.inverter_coordinators:
inverter_coordinator.add_entities_for_seen_keys( inverter_coordinator.add_entities_for_seen_keys(
async_add_entities, InverterSensor async_add_entities, InverterSensor

View File

@ -59,7 +59,7 @@ def mock_responses(
) )
aioclient_mock.get( aioclient_mock.get(
f"{host}/solar_api/v1/GetInverterInfo.cgi", 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( aioclient_mock.get(
f"{host}/solar_api/v1/GetLoggerInfo.cgi", f"{host}/solar_api/v1/GetLoggerInfo.cgi",

View File

@ -0,0 +1,5 @@
{
"APIVersion": 1,
"BaseURL": "/solar_api/v1/",
"CompatibilityRange": "1.5-18"
}

View File

@ -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"
}
}

View File

@ -0,0 +1,14 @@
{
"Body": {
"Data": {}
},
"Head": {
"RequestArguments": {},
"Status": {
"Code": 0,
"Reason": "",
"UserMessage": ""
},
"Timestamp": "2023-06-27T21:48:52+02:00"
}
}

View File

@ -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"
}
}

View File

@ -0,0 +1,14 @@
{
"Body": {
"Data": {}
},
"Head": {
"RequestArguments": {},
"Status": {
"Code": 0,
"Reason": "",
"UserMessage": ""
},
"Timestamp": "2023-06-27T21:48:52+02:00"
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -1,14 +1,18 @@
"""Test the Fronius integration.""" """Test the Fronius integration."""
from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch
from pyfronius import FroniusError 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.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant 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 . import mock_responses, setup_fronius_integration
from tests.common import async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker from tests.test_util.aiohttp import AiohttpClientMocker
@ -53,3 +57,82 @@ async def test_inverter_error(
): ):
config_entry = await setup_fronius_integration(hass) config_entry = await setup_fronius_integration(hass)
assert config_entry.state is ConfigEntryState.SETUP_RETRY 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
)