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
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

View File

@ -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):

View File

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

View File

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

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."""
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
)