Periodically re-scan for Fronius inverters that were offline while setup (#96538)
parent
65ebb6a74f
commit
e29b6408f6
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"APIVersion": 1,
|
||||
"BaseURL": "/solar_api/v1/",
|
||||
"CompatibilityRange": "1.5-18"
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"Body": {
|
||||
"Data": {}
|
||||
},
|
||||
"Head": {
|
||||
"RequestArguments": {},
|
||||
"Status": {
|
||||
"Code": 0,
|
||||
"Reason": "",
|
||||
"UserMessage": ""
|
||||
},
|
||||
"Timestamp": "2023-06-27T21:48:52+02:00"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"Body": {
|
||||
"Data": {}
|
||||
},
|
||||
"Head": {
|
||||
"RequestArguments": {},
|
||||
"Status": {
|
||||
"Code": 0,
|
||||
"Reason": "",
|
||||
"UserMessage": ""
|
||||
},
|
||||
"Timestamp": "2023-06-27T21:48:52+02:00"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue