Rewrite Fronius integration (#59686)
* Add unique_id and use DataUpdateCoordinator in Fronius (#57879)
* initial refactoring commit - meters
- config_flow (no strings, no tests yet)
- import yaml config
- FroniusSolarNet class for holding Fronius object , coordinators and some common data
- meter descriptions
- update coordinator
- entities (including devices)
* storage controllers
* error handling on init; inverter unique_id
* inverters
* power_flow
* fix VA, var, varh not valid for device_class power/energy
and add custom icons
* add SolarNet device for system wide values
* cleanup
* config_flow strings
* test config_flow
* use pyfronius 0.7.0
* enable strict typing
* remove TODO comments
* fix lint errors; move FroniusSensorEntity to sensor.py
* power_flow as optional coordinator
API V0 doesn't support power_flow endpoint
* show error message in logs
* prevent parallel requests to one host
* logger_info coordinator
* store FroniusSolarNet reference directly in coordinator
* cleanup coordinators when unloading entry
* round floats returned by Fronius API
* default icons for grid im/export tariffs
* small typing fix
* Update homeassistant/components/fronius/sensor.py
Co-authored-by: Brett Adams <Bre77@users.noreply.github.com>
* DC icons
* prepend names with "Fronius" and device type
to get more reasonable default entity_ids (eg. have them next to each other when alphabetically sorted)
* remove config_flow and devices
* rename _FroniusUpdateCoordinator to FroniusCoordinatorBase
and mark ABC
* move SensorEntityDescriptions to sensor.py
* Revert "move SensorEntityDescriptions to sensor.py"
This reverts commit 2e5a726eb6
.
* Don't raise ConfigEntryNotReady and use regular refresh method
* move bridge initialization out of helper class
* no coverage tests
* power_flow update interval 10 seconds
* move SensorEntityDescriptions to sensor.py
without introducing a circular dependency
* deprecation warning for CONF_MONITORED_CONDITIONS
* remove extra_state_attributes form meter sensor entities
* readd diagnostic entities
* decouple default entity_id from default name
* use key instead of name for entity_id
and make deprecated config key optional
* adjust tests
* use old entity_ids
these changes are now backwards compatible
* check coverage
* simplify entity description definitions
* restore entity names of previous implementation
Co-authored-by: Brett Adams <Bre77@users.noreply.github.com>
* Add config_flow for Fronius integration (#59677)
* Cleanup Fronius config_flow and tests (#60094)
* Add devices to Fronius integration (#60104)
* New entity names for Fronius entities (#60215)
* Adaptive update interval for Fronius coordinators (#60192)
Co-authored-by: Brett Adams <Bre77@users.noreply.github.com>
pull/60258/head
parent
314f593066
commit
3dac661480
|
@ -372,7 +372,6 @@ omit =
|
|||
homeassistant/components/fritzbox_callmonitor/const.py
|
||||
homeassistant/components/fritzbox_callmonitor/base.py
|
||||
homeassistant/components/fritzbox_callmonitor/sensor.py
|
||||
homeassistant/components/fronius/sensor.py
|
||||
homeassistant/components/frontier_silicon/media_player.py
|
||||
homeassistant/components/futurenow/light.py
|
||||
homeassistant/components/garadget/cover.py
|
||||
|
|
|
@ -49,6 +49,7 @@ homeassistant.components.flunearyou.*
|
|||
homeassistant.components.flux_led.*
|
||||
homeassistant.components.forecast_solar.*
|
||||
homeassistant.components.fritzbox.*
|
||||
homeassistant.components.fronius.*
|
||||
homeassistant.components.frontend.*
|
||||
homeassistant.components.fritz.*
|
||||
homeassistant.components.geo_location.*
|
||||
|
|
|
@ -186,7 +186,7 @@ homeassistant/components/freebox/* @hacf-fr @Quentame
|
|||
homeassistant/components/freedompro/* @stefano055415
|
||||
homeassistant/components/fritz/* @mammuth @AaronDavidSchneider @chemelli74
|
||||
homeassistant/components/fritzbox/* @mib1185 @flabbamann
|
||||
homeassistant/components/fronius/* @nielstron
|
||||
homeassistant/components/fronius/* @nielstron @farmio
|
||||
homeassistant/components/frontend/* @home-assistant/frontend
|
||||
homeassistant/components/garages_amsterdam/* @klaasnicolaas
|
||||
homeassistant/components/gdacs/* @exxamalte
|
||||
|
|
|
@ -1 +1,204 @@
|
|||
"""The Fronius component."""
|
||||
"""The Fronius integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from pyfronius import Fronius, FroniusError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION, CONF_HOST
|
||||
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 .const import DOMAIN, SOLAR_NET_ID_SYSTEM, FroniusDeviceInfo
|
||||
from .coordinator import (
|
||||
FroniusCoordinatorBase,
|
||||
FroniusInverterUpdateCoordinator,
|
||||
FroniusLoggerUpdateCoordinator,
|
||||
FroniusMeterUpdateCoordinator,
|
||||
FroniusPowerFlowUpdateCoordinator,
|
||||
FroniusStorageUpdateCoordinator,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS: list[str] = ["sensor"]
|
||||
|
||||
FroniusCoordinatorType = TypeVar("FroniusCoordinatorType", bound=FroniusCoordinatorBase)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up fronius from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
fronius = Fronius(async_get_clientsession(hass), host)
|
||||
solar_net = FroniusSolarNet(hass, entry, fronius)
|
||||
await solar_net.init_devices()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = solar_net
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
# reload on config_entry update
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_entry))
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
solar_net = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
while solar_net.cleanup_callbacks:
|
||||
solar_net.cleanup_callbacks.pop()()
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Update a given config entry."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
class FroniusSolarNet:
|
||||
"""The FroniusSolarNet class routes received values to sensor entities."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry, fronius: Fronius
|
||||
) -> None:
|
||||
"""Initialize FroniusSolarNet class."""
|
||||
self.hass = hass
|
||||
self.cleanup_callbacks: list[Callable[[], None]] = []
|
||||
self.config_entry = entry
|
||||
self.coordinator_lock = asyncio.Lock()
|
||||
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
|
||||
# prepended by "solar_net_" to have individual device for whole system (power_flow)
|
||||
self.solar_net_device_id = f"solar_net_{entry.unique_id}"
|
||||
self.system_device_info: DeviceInfo | None = None
|
||||
|
||||
self.inverter_coordinators: list[FroniusInverterUpdateCoordinator] = []
|
||||
self.logger_coordinator: FroniusLoggerUpdateCoordinator | None = None
|
||||
self.meter_coordinator: FroniusMeterUpdateCoordinator | None = None
|
||||
self.power_flow_coordinator: FroniusPowerFlowUpdateCoordinator | None = None
|
||||
self.storage_coordinator: FroniusStorageUpdateCoordinator | None = None
|
||||
|
||||
async def init_devices(self) -> None:
|
||||
"""Initialize DataUpdateCoordinators for SolarNet devices."""
|
||||
if self.config_entry.data["is_logger"]:
|
||||
self.logger_coordinator = FroniusLoggerUpdateCoordinator(
|
||||
hass=self.hass,
|
||||
solar_net=self,
|
||||
logger=_LOGGER,
|
||||
name=f"{DOMAIN}_logger_{self.host}",
|
||||
)
|
||||
await self.logger_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# _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)
|
||||
|
||||
self.meter_coordinator = await self._init_optional_coordinator(
|
||||
FroniusMeterUpdateCoordinator(
|
||||
hass=self.hass,
|
||||
solar_net=self,
|
||||
logger=_LOGGER,
|
||||
name=f"{DOMAIN}_meters_{self.host}",
|
||||
)
|
||||
)
|
||||
|
||||
self.power_flow_coordinator = await self._init_optional_coordinator(
|
||||
FroniusPowerFlowUpdateCoordinator(
|
||||
hass=self.hass,
|
||||
solar_net=self,
|
||||
logger=_LOGGER,
|
||||
name=f"{DOMAIN}_power_flow_{self.host}",
|
||||
)
|
||||
)
|
||||
|
||||
self.storage_coordinator = await self._init_optional_coordinator(
|
||||
FroniusStorageUpdateCoordinator(
|
||||
hass=self.hass,
|
||||
solar_net=self,
|
||||
logger=_LOGGER,
|
||||
name=f"{DOMAIN}_storages_{self.host}",
|
||||
)
|
||||
)
|
||||
|
||||
async def _create_solar_net_device(self) -> DeviceInfo:
|
||||
"""Create a device for the Fronius SolarNet system."""
|
||||
solar_net_device: DeviceInfo = DeviceInfo(
|
||||
configuration_url=self.host,
|
||||
identifiers={(DOMAIN, self.solar_net_device_id)},
|
||||
manufacturer="Fronius",
|
||||
name="SolarNet",
|
||||
)
|
||||
if self.logger_coordinator:
|
||||
_logger_info = self.logger_coordinator.data[SOLAR_NET_ID_SYSTEM]
|
||||
solar_net_device[ATTR_MODEL] = _logger_info["product_type"]["value"]
|
||||
solar_net_device[ATTR_SW_VERSION] = _logger_info["software_version"][
|
||||
"value"
|
||||
]
|
||||
|
||||
device_registry = await dr.async_get_registry(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
**solar_net_device,
|
||||
)
|
||||
return solar_net_device
|
||||
|
||||
async def _get_inverter_infos(self) -> list[FroniusDeviceInfo]:
|
||||
"""Get information about the inverters in the SolarNet system."""
|
||||
try:
|
||||
_inverter_info = await self.fronius.inverter_info()
|
||||
except FroniusError as err:
|
||||
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"]
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
manufacturer=inverter["device_type"].get("manufacturer", "Fronius"),
|
||||
model=inverter["device_type"].get(
|
||||
"model", inverter["device_type"]["value"]
|
||||
),
|
||||
name=inverter.get("custom_name", {}).get("value"),
|
||||
via_device=(DOMAIN, self.solar_net_device_id),
|
||||
)
|
||||
inverter_infos.append(
|
||||
FroniusDeviceInfo(
|
||||
device_info=device_info,
|
||||
solar_net_id=solar_net_id,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
)
|
||||
return inverter_infos
|
||||
|
||||
@staticmethod
|
||||
async def _init_optional_coordinator(
|
||||
coordinator: FroniusCoordinatorType,
|
||||
) -> FroniusCoordinatorType | None:
|
||||
"""Initialize an update coordinator and return it if devices are found."""
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except ConfigEntryNotReady:
|
||||
return None
|
||||
# keep coordinator only if devices are found
|
||||
# else ConfigEntryNotReady raised form KeyError
|
||||
# in FroniusMeterUpdateCoordinator._get_fronius_device_data
|
||||
return coordinator
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
"""Config flow for Fronius integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyfronius import Fronius, FroniusError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_HOST, CONF_RESOURCE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, FroniusConfigEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(
|
||||
hass: HomeAssistant, data: dict[str, Any]
|
||||
) -> tuple[str, FroniusConfigEntryData]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
host = data[CONF_HOST]
|
||||
fronius = Fronius(async_get_clientsession(hass), host)
|
||||
|
||||
try:
|
||||
datalogger_info: dict[str, Any]
|
||||
datalogger_info = await fronius.current_logger_info()
|
||||
except FroniusError as err:
|
||||
_LOGGER.debug(err)
|
||||
else:
|
||||
logger_uid: str = datalogger_info["unique_identifier"]["value"]
|
||||
return logger_uid, FroniusConfigEntryData(
|
||||
host=host,
|
||||
is_logger=True,
|
||||
)
|
||||
# Gen24 devices don't provide GetLoggerInfo
|
||||
try:
|
||||
inverter_info = await fronius.inverter_info()
|
||||
first_inverter = next(inverter for inverter in inverter_info["inverters"])
|
||||
except FroniusError as err:
|
||||
_LOGGER.debug(err)
|
||||
raise CannotConnect from err
|
||||
except StopIteration as err:
|
||||
raise CannotConnect("No supported Fronius SolarNet device found.") from err
|
||||
first_inverter_uid: str = first_inverter["unique_id"]["value"]
|
||||
return first_inverter_uid, FroniusConfigEntryData(
|
||||
host=host,
|
||||
is_logger=False,
|
||||
)
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Fronius."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||
)
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
unique_id, info = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(unique_id, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates=dict(info), reload_on_update=False
|
||||
)
|
||||
title = (
|
||||
f"SolarNet {'Datalogger' if info['is_logger'] else 'Inverter'}"
|
||||
f" at {info['host']}"
|
||||
)
|
||||
return self.async_create_entry(title=title, data=info)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_import(self, conf: dict) -> FlowResult:
|
||||
"""Import a configuration from config.yaml."""
|
||||
return await self.async_step_user(user_input={CONF_HOST: conf[CONF_RESOURCE]})
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
|
@ -0,0 +1,25 @@
|
|||
"""Constants for the Fronius integration."""
|
||||
from typing import Final, NamedTuple, TypedDict
|
||||
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
|
||||
DOMAIN: Final = "fronius"
|
||||
|
||||
SolarNetId = str
|
||||
SOLAR_NET_ID_POWER_FLOW: SolarNetId = "power_flow"
|
||||
SOLAR_NET_ID_SYSTEM: SolarNetId = "system"
|
||||
|
||||
|
||||
class FroniusConfigEntryData(TypedDict):
|
||||
"""ConfigEntry for the Fronius integration."""
|
||||
|
||||
host: str
|
||||
is_logger: bool
|
||||
|
||||
|
||||
class FroniusDeviceInfo(NamedTuple):
|
||||
"""Information about a Fronius inverter device."""
|
||||
|
||||
device_info: DeviceInfo
|
||||
solar_net_id: SolarNetId
|
||||
unique_id: str
|
|
@ -0,0 +1,184 @@
|
|||
"""DataUpdateCoordinators for the Fronius integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any, Dict, TypeVar
|
||||
|
||||
from pyfronius import FroniusError
|
||||
|
||||
from homeassistant.components.sensor import SensorEntityDescription
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
SOLAR_NET_ID_POWER_FLOW,
|
||||
SOLAR_NET_ID_SYSTEM,
|
||||
FroniusDeviceInfo,
|
||||
SolarNetId,
|
||||
)
|
||||
from .sensor import (
|
||||
INVERTER_ENTITY_DESCRIPTIONS,
|
||||
LOGGER_ENTITY_DESCRIPTIONS,
|
||||
METER_ENTITY_DESCRIPTIONS,
|
||||
POWER_FLOW_ENTITY_DESCRIPTIONS,
|
||||
STORAGE_ENTITY_DESCRIPTIONS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import FroniusSolarNet
|
||||
from .sensor import _FroniusSensorEntity
|
||||
|
||||
FroniusEntityType = TypeVar("FroniusEntityType", bound=_FroniusSensorEntity)
|
||||
|
||||
|
||||
class FroniusCoordinatorBase(
|
||||
ABC, DataUpdateCoordinator[Dict[SolarNetId, Dict[str, Any]]]
|
||||
):
|
||||
"""Query Fronius endpoint and keep track of seen conditions."""
|
||||
|
||||
default_interval: timedelta
|
||||
error_interval: timedelta
|
||||
valid_descriptions: list[SensorEntityDescription]
|
||||
|
||||
def __init__(self, *args: Any, solar_net: FroniusSolarNet, **kwargs: Any) -> None:
|
||||
"""Set up the FroniusCoordinatorBase class."""
|
||||
self._failed_update_count = 0
|
||||
self.solar_net = solar_net
|
||||
# unregistered_keys are used to create entities in platform module
|
||||
self.unregistered_keys: dict[SolarNetId, set[str]] = {}
|
||||
super().__init__(*args, update_interval=self.default_interval, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def _update_method(self) -> dict[SolarNetId, Any]:
|
||||
"""Return data per solar net id from pyfronius."""
|
||||
|
||||
async def _async_update_data(self) -> dict[SolarNetId, Any]:
|
||||
"""Fetch the latest data from the source."""
|
||||
async with self.solar_net.coordinator_lock:
|
||||
try:
|
||||
data = await self._update_method()
|
||||
except FroniusError as err:
|
||||
self._failed_update_count += 1
|
||||
if self._failed_update_count == 3:
|
||||
self.update_interval = self.error_interval
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
if self._failed_update_count != 0:
|
||||
self._failed_update_count = 0
|
||||
self.update_interval = self.default_interval
|
||||
|
||||
for solar_net_id in data:
|
||||
if solar_net_id not in self.unregistered_keys:
|
||||
# id seen for the first time
|
||||
self.unregistered_keys[solar_net_id] = {
|
||||
desc.key for desc in self.valid_descriptions
|
||||
}
|
||||
return data
|
||||
|
||||
@callback
|
||||
def add_entities_for_seen_keys(
|
||||
self,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
entity_constructor: type[FroniusEntityType],
|
||||
) -> None:
|
||||
"""
|
||||
Add entities for received keys and registers listener for future seen keys.
|
||||
|
||||
Called from a platforms `async_setup_entry`.
|
||||
"""
|
||||
|
||||
@callback
|
||||
def _add_entities_for_unregistered_keys() -> None:
|
||||
"""Add entities for keys seen for the first time."""
|
||||
new_entities: list = []
|
||||
for solar_net_id, device_data in self.data.items():
|
||||
for key in self.unregistered_keys[solar_net_id].intersection(
|
||||
device_data
|
||||
):
|
||||
new_entities.append(entity_constructor(self, key, solar_net_id))
|
||||
self.unregistered_keys[solar_net_id].remove(key)
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
_add_entities_for_unregistered_keys()
|
||||
self.solar_net.cleanup_callbacks.append(
|
||||
self.async_add_listener(_add_entities_for_unregistered_keys)
|
||||
)
|
||||
|
||||
|
||||
class FroniusInverterUpdateCoordinator(FroniusCoordinatorBase):
|
||||
"""Query Fronius device inverter endpoint and keep track of seen conditions."""
|
||||
|
||||
default_interval = timedelta(minutes=1)
|
||||
error_interval = timedelta(minutes=10)
|
||||
valid_descriptions = INVERTER_ENTITY_DESCRIPTIONS
|
||||
|
||||
def __init__(
|
||||
self, *args: Any, inverter_info: FroniusDeviceInfo, **kwargs: Any
|
||||
) -> None:
|
||||
"""Set up a Fronius inverter device scope coordinator."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.inverter_info = inverter_info
|
||||
|
||||
async def _update_method(self) -> dict[SolarNetId, Any]:
|
||||
"""Return data per solar net id from pyfronius."""
|
||||
data = await self.solar_net.fronius.current_inverter_data(
|
||||
self.inverter_info.solar_net_id
|
||||
)
|
||||
# wrap a single devices data in a dict with solar_net_id key for
|
||||
# FroniusCoordinatorBase _async_update_data and add_entities_for_seen_keys
|
||||
return {self.inverter_info.solar_net_id: data}
|
||||
|
||||
|
||||
class FroniusLoggerUpdateCoordinator(FroniusCoordinatorBase):
|
||||
"""Query Fronius logger info endpoint and keep track of seen conditions."""
|
||||
|
||||
default_interval = timedelta(hours=1)
|
||||
error_interval = timedelta(hours=1)
|
||||
valid_descriptions = LOGGER_ENTITY_DESCRIPTIONS
|
||||
|
||||
async def _update_method(self) -> dict[SolarNetId, Any]:
|
||||
"""Return data per solar net id from pyfronius."""
|
||||
data = await self.solar_net.fronius.current_logger_info()
|
||||
return {SOLAR_NET_ID_SYSTEM: data}
|
||||
|
||||
|
||||
class FroniusMeterUpdateCoordinator(FroniusCoordinatorBase):
|
||||
"""Query Fronius system meter endpoint and keep track of seen conditions."""
|
||||
|
||||
default_interval = timedelta(minutes=1)
|
||||
error_interval = timedelta(minutes=10)
|
||||
valid_descriptions = METER_ENTITY_DESCRIPTIONS
|
||||
|
||||
async def _update_method(self) -> dict[SolarNetId, Any]:
|
||||
"""Return data per solar net id from pyfronius."""
|
||||
data = await self.solar_net.fronius.current_system_meter_data()
|
||||
return data["meters"] # type: ignore[no-any-return]
|
||||
|
||||
|
||||
class FroniusPowerFlowUpdateCoordinator(FroniusCoordinatorBase):
|
||||
"""Query Fronius power flow endpoint and keep track of seen conditions."""
|
||||
|
||||
default_interval = timedelta(seconds=10)
|
||||
error_interval = timedelta(minutes=3)
|
||||
valid_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS
|
||||
|
||||
async def _update_method(self) -> dict[SolarNetId, Any]:
|
||||
"""Return data per solar net id from pyfronius."""
|
||||
data = await self.solar_net.fronius.current_power_flow()
|
||||
return {SOLAR_NET_ID_POWER_FLOW: data}
|
||||
|
||||
|
||||
class FroniusStorageUpdateCoordinator(FroniusCoordinatorBase):
|
||||
"""Query Fronius system storage endpoint and keep track of seen conditions."""
|
||||
|
||||
default_interval = timedelta(minutes=1)
|
||||
error_interval = timedelta(minutes=10)
|
||||
valid_descriptions = STORAGE_ENTITY_DESCRIPTIONS
|
||||
|
||||
async def _update_method(self) -> dict[SolarNetId, Any]:
|
||||
"""Return data per solar net id from pyfronius."""
|
||||
data = await self.solar_net.fronius.current_system_storage_data()
|
||||
return data["storages"] # type: ignore[no-any-return]
|
|
@ -1,8 +1,9 @@
|
|||
{
|
||||
"domain": "fronius",
|
||||
"name": "Fronius",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fronius",
|
||||
"requirements": ["pyfronius==0.7.0"],
|
||||
"codeowners": ["@nielstron"],
|
||||
"codeowners": ["@nielstron", "@farmio"],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Fronius SolarNet",
|
||||
"description": "Configure the IP address or local hostname of your Fronius device.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host"
|
||||
},
|
||||
"description": "Configure the IP address or local hostname of your Fronius device.",
|
||||
"title": "Fronius SolarNet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -102,6 +102,7 @@ FLOWS = [
|
|||
"fritz",
|
||||
"fritzbox",
|
||||
"fritzbox_callmonitor",
|
||||
"fronius",
|
||||
"garages_amsterdam",
|
||||
"gdacs",
|
||||
"geofency",
|
||||
|
|
11
mypy.ini
11
mypy.ini
|
@ -550,6 +550,17 @@ no_implicit_optional = true
|
|||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.fronius.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.frontend.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
|
|
@ -1,23 +1,72 @@
|
|||
"""Tests for the Fronius integration."""
|
||||
from homeassistant.components.fronius.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_RESOURCE
|
||||
from homeassistant.setup import async_setup_component
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
from .const import DOMAIN, MOCK_HOST
|
||||
MOCK_HOST = "http://fronius"
|
||||
MOCK_UID = "123.4567890" # has to match mocked logger unique_id
|
||||
|
||||
|
||||
async def setup_fronius_integration(hass, devices):
|
||||
async def setup_fronius_integration(hass):
|
||||
"""Create the Fronius integration."""
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
SENSOR_DOMAIN,
|
||||
{
|
||||
SENSOR_DOMAIN: {
|
||||
"platform": DOMAIN,
|
||||
CONF_RESOURCE: MOCK_HOST,
|
||||
CONF_MONITORED_CONDITIONS: devices,
|
||||
}
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=MOCK_UID,
|
||||
data={
|
||||
CONF_HOST: MOCK_HOST,
|
||||
"is_logger": True,
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
def mock_responses(
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
host: str = MOCK_HOST,
|
||||
night: bool = False,
|
||||
) -> None:
|
||||
"""Mock responses for Fronius Symo inverter with meter."""
|
||||
aioclient_mock.clear_requests()
|
||||
_day_or_night = "night" if night else "day"
|
||||
|
||||
aioclient_mock.get(
|
||||
f"{host}/solar_api/GetAPIVersion.cgi",
|
||||
text=load_fixture("symo/GetAPIVersion.json", "fronius"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{host}/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&"
|
||||
"DeviceId=1&DataCollection=CommonInverterData",
|
||||
text=load_fixture(
|
||||
f"symo/GetInverterRealtimeDate_Device_1_{_day_or_night}.json", "fronius"
|
||||
),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{host}/solar_api/v1/GetInverterInfo.cgi",
|
||||
text=load_fixture("symo/GetInverterInfo.json", "fronius"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{host}/solar_api/v1/GetLoggerInfo.cgi",
|
||||
text=load_fixture("symo/GetLoggerInfo.json", "fronius"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{host}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=Device&DeviceId=0",
|
||||
text=load_fixture("symo/GetMeterRealtimeData_Device_0.json", "fronius"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{host}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System",
|
||||
text=load_fixture("symo/GetMeterRealtimeData_System.json", "fronius"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{host}/solar_api/v1/GetPowerFlowRealtimeData.fcgi",
|
||||
text=load_fixture(
|
||||
f"symo/GetPowerFlowRealtimeData_{_day_or_night}.json", "fronius"
|
||||
),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{host}/solar_api/v1/GetStorageRealtimeData.cgi?Scope=System",
|
||||
text=load_fixture("symo/GetStorageRealtimeData_System.json", "fronius"),
|
||||
)
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
"""Constants for Fronius tests."""
|
||||
|
||||
DOMAIN = "fronius"
|
||||
MOCK_HOST = "http://fronius"
|
|
@ -0,0 +1,263 @@
|
|||
"""Test the Fronius config flow."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from pyfronius import FroniusError
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.fronius.const import DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import CONF_HOST, CONF_RESOURCE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import (
|
||||
RESULT_TYPE_ABORT,
|
||||
RESULT_TYPE_CREATE_ENTRY,
|
||||
RESULT_TYPE_FORM,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import MOCK_HOST, mock_responses
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
INVERTER_INFO_RETURN_VALUE = {
|
||||
"inverters": [
|
||||
{
|
||||
"device_id": {"value": "1"},
|
||||
"unique_id": {"value": "1234567"},
|
||||
}
|
||||
]
|
||||
}
|
||||
LOGGER_INFO_RETURN_VALUE = {"unique_identifier": {"value": "123.4567"}}
|
||||
|
||||
|
||||
async def test_form_with_logger(hass: HomeAssistant) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["errors"] is None
|
||||
|
||||
with patch(
|
||||
"pyfronius.Fronius.current_logger_info",
|
||||
return_value=LOGGER_INFO_RETURN_VALUE,
|
||||
), patch(
|
||||
"homeassistant.components.fronius.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": "10.9.8.1",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2["title"] == "SolarNet Datalogger at 10.9.8.1"
|
||||
assert result2["data"] == {
|
||||
"host": "10.9.8.1",
|
||||
"is_logger": True,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_with_inverter(hass: HomeAssistant) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert result["errors"] is None
|
||||
|
||||
with patch(
|
||||
"pyfronius.Fronius.current_logger_info",
|
||||
side_effect=FroniusError,
|
||||
), patch(
|
||||
"pyfronius.Fronius.inverter_info",
|
||||
return_value=INVERTER_INFO_RETURN_VALUE,
|
||||
), patch(
|
||||
"homeassistant.components.fronius.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": "10.9.1.1",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2["title"] == "SolarNet Inverter at 10.9.1.1"
|
||||
assert result2["data"] == {
|
||||
"host": "10.9.1.1",
|
||||
"is_logger": False,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pyfronius.Fronius.current_logger_info",
|
||||
side_effect=FroniusError,
|
||||
), patch(
|
||||
"pyfronius.Fronius.inverter_info",
|
||||
side_effect=FroniusError,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": "1.1.1.1",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_form_no_device(hass: HomeAssistant) -> None:
|
||||
"""Test we handle no device found error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pyfronius.Fronius.current_logger_info",
|
||||
side_effect=FroniusError,
|
||||
), patch(
|
||||
"pyfronius.Fronius.inverter_info",
|
||||
return_value={"inverters": []},
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": "1.1.1.1",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_form_unexpected(hass: HomeAssistant) -> None:
|
||||
"""Test we handle unexpected error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pyfronius.Fronius.current_logger_info",
|
||||
side_effect=KeyError,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": "1.1.1.1",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_form_already_existing(hass):
|
||||
"""Test existing entry."""
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="123.4567",
|
||||
data={CONF_HOST: "10.9.8.1", "is_logger": True},
|
||||
).add_to_hass(hass)
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
with patch(
|
||||
"pyfronius.Fronius.current_logger_info",
|
||||
return_value=LOGGER_INFO_RETURN_VALUE,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": "10.9.8.1",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_ABORT
|
||||
assert result2["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_form_updates_host(hass, aioclient_mock):
|
||||
"""Test existing entry gets updated."""
|
||||
old_host = "http://10.1.0.1"
|
||||
new_host = "http://10.1.0.2"
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="123.4567890", # has to match mocked logger unique_id
|
||||
data={
|
||||
CONF_HOST: old_host,
|
||||
"is_logger": True,
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_responses(aioclient_mock, host=old_host)
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_responses(aioclient_mock, host=new_host)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": new_host,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_ABORT
|
||||
assert result2["reason"] == "already_configured"
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].data == {
|
||||
"host": new_host,
|
||||
"is_logger": True,
|
||||
}
|
||||
|
||||
|
||||
async def test_import(hass, aioclient_mock):
|
||||
"""Test import step."""
|
||||
mock_responses(aioclient_mock)
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
SENSOR_DOMAIN,
|
||||
{
|
||||
SENSOR_DOMAIN: {
|
||||
"platform": DOMAIN,
|
||||
CONF_RESOURCE: MOCK_HOST,
|
||||
}
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
fronius_entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(fronius_entries) == 1
|
||||
|
||||
test_entry = fronius_entries[0]
|
||||
assert test_entry.unique_id == "123.4567890" # has to match mocked logger unique_id
|
||||
assert test_entry.data == {
|
||||
"host": MOCK_HOST,
|
||||
"is_logger": True,
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
"""Test the Fronius update coordinators."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from pyfronius import FroniusError
|
||||
|
||||
from homeassistant.components.fronius.coordinator import (
|
||||
FroniusInverterUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.util import dt
|
||||
|
||||
from . import mock_responses, setup_fronius_integration
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
async def test_adaptive_update_interval(hass, aioclient_mock):
|
||||
"""Test coordinators changing their update interval when inverter not available."""
|
||||
with patch("pyfronius.Fronius.current_inverter_data") as mock_inverter_data:
|
||||
mock_responses(aioclient_mock)
|
||||
await setup_fronius_integration(hass)
|
||||
assert mock_inverter_data.call_count == 1
|
||||
|
||||
async_fire_time_changed(
|
||||
hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_inverter_data.call_count == 2
|
||||
|
||||
mock_inverter_data.side_effect = FroniusError
|
||||
# first 3 requests at default interval - 4th has different interval
|
||||
for _ in range(4):
|
||||
async_fire_time_changed(
|
||||
hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_inverter_data.call_count == 5
|
||||
async_fire_time_changed(
|
||||
hass, dt.utcnow() + FroniusInverterUpdateCoordinator.error_interval
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_inverter_data.call_count == 6
|
||||
|
||||
mock_inverter_data.side_effect = None
|
||||
# next successful request resets to default interval
|
||||
async_fire_time_changed(
|
||||
hass, dt.utcnow() + FroniusInverterUpdateCoordinator.error_interval
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_inverter_data.call_count == 7
|
||||
|
||||
async_fire_time_changed(
|
||||
hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_inverter_data.call_count == 8
|
|
@ -0,0 +1,23 @@
|
|||
"""Test the Fronius integration."""
|
||||
from homeassistant.components.fronius.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
|
||||
from . import mock_responses, setup_fronius_integration
|
||||
|
||||
|
||||
async def test_unload_config_entry(hass, aioclient_mock):
|
||||
"""Test that configuration entry supports unloading."""
|
||||
mock_responses(aioclient_mock)
|
||||
await setup_fronius_integration(hass)
|
||||
|
||||
fronius_entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(fronius_entries) == 1
|
||||
|
||||
test_entry = fronius_entries[0]
|
||||
assert test_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert await hass.config_entries.async_unload(test_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert test_entry.state is ConfigEntryState.NOT_LOADED
|
||||
assert not hass.data.get(DOMAIN)
|
|
@ -1,66 +1,15 @@
|
|||
"""Tests for the Fronius sensor platform."""
|
||||
|
||||
from homeassistant.components.fronius.sensor import (
|
||||
CONF_SCOPE,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
SCOPE_DEVICE,
|
||||
TYPE_INVERTER,
|
||||
TYPE_LOGGER_INFO,
|
||||
TYPE_METER,
|
||||
TYPE_POWER_FLOW,
|
||||
from homeassistant.components.fronius.coordinator import (
|
||||
FroniusInverterUpdateCoordinator,
|
||||
FroniusPowerFlowUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE, CONF_SENSOR_TYPE, STATE_UNKNOWN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.util import dt
|
||||
|
||||
from . import setup_fronius_integration
|
||||
from .const import MOCK_HOST
|
||||
from . import mock_responses, setup_fronius_integration
|
||||
|
||||
from tests.common import async_fire_time_changed, load_fixture
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
|
||||
def mock_responses(aioclient_mock: AiohttpClientMocker, night: bool = False) -> None:
|
||||
"""Mock responses for Fronius Symo inverter with meter."""
|
||||
aioclient_mock.clear_requests()
|
||||
_day_or_night = "night" if night else "day"
|
||||
|
||||
aioclient_mock.get(
|
||||
f"{MOCK_HOST}/solar_api/GetAPIVersion.cgi",
|
||||
text=load_fixture("symo/GetAPIVersion.json", "fronius"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{MOCK_HOST}/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&"
|
||||
"DeviceId=1&DataCollection=CommonInverterData",
|
||||
text=load_fixture(
|
||||
f"symo/GetInverterRealtimeDate_Device_1_{_day_or_night}.json", "fronius"
|
||||
),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{MOCK_HOST}/solar_api/v1/GetInverterInfo.cgi",
|
||||
text=load_fixture("symo/GetInverterInfo.json", "fronius"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{MOCK_HOST}/solar_api/v1/GetLoggerInfo.cgi",
|
||||
text=load_fixture("symo/GetLoggerInfo.json", "fronius"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{MOCK_HOST}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=Device&DeviceId=0",
|
||||
text=load_fixture("symo/GetMeterRealtimeData_Device_0.json", "fronius"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{MOCK_HOST}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System",
|
||||
text=load_fixture("symo/GetMeterRealtimeData_System.json", "fronius"),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{MOCK_HOST}/solar_api/v1/GetPowerFlowRealtimeData.fcgi",
|
||||
text=load_fixture(
|
||||
f"symo/GetPowerFlowRealtimeData_{_day_or_night}.json", "fronius"
|
||||
),
|
||||
)
|
||||
aioclient_mock.get(
|
||||
f"{MOCK_HOST}/solar_api/v1/GetStorageRealtimeData.cgi?Scope=System",
|
||||
text=load_fixture("symo/GetStorageRealtimeData_System.json", "fronius"),
|
||||
)
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
async def test_symo_inverter(hass, aioclient_mock):
|
||||
|
@ -72,15 +21,9 @@ async def test_symo_inverter(hass, aioclient_mock):
|
|||
|
||||
# Init at night
|
||||
mock_responses(aioclient_mock, night=True)
|
||||
config = {
|
||||
CONF_SENSOR_TYPE: TYPE_INVERTER,
|
||||
CONF_SCOPE: SCOPE_DEVICE,
|
||||
CONF_DEVICE: 1,
|
||||
}
|
||||
await setup_fronius_integration(hass, [config])
|
||||
await setup_fronius_integration(hass)
|
||||
|
||||
assert len(hass.states.async_all()) == 10
|
||||
# 5 ignored from DeviceStatus
|
||||
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 55
|
||||
assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 0)
|
||||
assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", 10828)
|
||||
assert_state("sensor.energy_total_fronius_inverter_1_http_fronius", 44186900)
|
||||
|
@ -89,10 +32,12 @@ async def test_symo_inverter(hass, aioclient_mock):
|
|||
|
||||
# Second test at daytime when inverter is producing
|
||||
mock_responses(aioclient_mock, night=False)
|
||||
async_fire_time_changed(hass, dt.utcnow() + DEFAULT_SCAN_INTERVAL)
|
||||
async_fire_time_changed(
|
||||
hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 14
|
||||
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59
|
||||
# 4 additional AC entities
|
||||
assert_state("sensor.current_dc_fronius_inverter_1_http_fronius", 2.19)
|
||||
assert_state("sensor.energy_day_fronius_inverter_1_http_fronius", 1113)
|
||||
|
@ -114,12 +59,9 @@ async def test_symo_logger(hass, aioclient_mock):
|
|||
assert state.state == str(expected_state)
|
||||
|
||||
mock_responses(aioclient_mock)
|
||||
config = {
|
||||
CONF_SENSOR_TYPE: TYPE_LOGGER_INFO,
|
||||
}
|
||||
await setup_fronius_integration(hass, [config])
|
||||
await setup_fronius_integration(hass)
|
||||
|
||||
assert len(hass.states.async_all()) == 12
|
||||
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59
|
||||
# ignored constant entities:
|
||||
# hardware_platform, hardware_version, product_type
|
||||
# software_version, time_zone, time_zone_location
|
||||
|
@ -128,7 +70,7 @@ async def test_symo_logger(hass, aioclient_mock):
|
|||
# states are rounded to 2 decimals
|
||||
assert_state(
|
||||
"sensor.cash_factor_fronius_logger_info_0_http_fronius",
|
||||
0.08,
|
||||
0.078,
|
||||
)
|
||||
assert_state(
|
||||
"sensor.co2_factor_fronius_logger_info_0_http_fronius",
|
||||
|
@ -149,21 +91,16 @@ async def test_symo_meter(hass, aioclient_mock):
|
|||
assert state.state == str(expected_state)
|
||||
|
||||
mock_responses(aioclient_mock)
|
||||
config = {
|
||||
CONF_SENSOR_TYPE: TYPE_METER,
|
||||
CONF_SCOPE: SCOPE_DEVICE,
|
||||
CONF_DEVICE: 0,
|
||||
}
|
||||
await setup_fronius_integration(hass, [config])
|
||||
await setup_fronius_integration(hass)
|
||||
|
||||
assert len(hass.states.async_all()) == 39
|
||||
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 59
|
||||
# ignored entities:
|
||||
# manufacturer, model, serial, enable, timestamp, visible, meter_location
|
||||
#
|
||||
# states are rounded to 2 decimals
|
||||
assert_state("sensor.current_ac_phase_1_fronius_meter_0_http_fronius", 7.75)
|
||||
assert_state("sensor.current_ac_phase_1_fronius_meter_0_http_fronius", 7.755)
|
||||
assert_state("sensor.current_ac_phase_2_fronius_meter_0_http_fronius", 6.68)
|
||||
assert_state("sensor.current_ac_phase_3_fronius_meter_0_http_fronius", 10.1)
|
||||
assert_state("sensor.current_ac_phase_3_fronius_meter_0_http_fronius", 10.102)
|
||||
assert_state(
|
||||
"sensor.energy_reactive_ac_consumed_fronius_meter_0_http_fronius", 59960790
|
||||
)
|
||||
|
@ -175,9 +112,9 @@ async def test_symo_meter(hass, aioclient_mock):
|
|||
assert_state("sensor.energy_real_consumed_fronius_meter_0_http_fronius", 15303334)
|
||||
assert_state("sensor.energy_real_produced_fronius_meter_0_http_fronius", 35623065)
|
||||
assert_state("sensor.frequency_phase_average_fronius_meter_0_http_fronius", 50)
|
||||
assert_state("sensor.power_apparent_phase_1_fronius_meter_0_http_fronius", 1772.79)
|
||||
assert_state("sensor.power_apparent_phase_2_fronius_meter_0_http_fronius", 1527.05)
|
||||
assert_state("sensor.power_apparent_phase_3_fronius_meter_0_http_fronius", 2333.56)
|
||||
assert_state("sensor.power_apparent_phase_1_fronius_meter_0_http_fronius", 1772.793)
|
||||
assert_state("sensor.power_apparent_phase_2_fronius_meter_0_http_fronius", 1527.048)
|
||||
assert_state("sensor.power_apparent_phase_3_fronius_meter_0_http_fronius", 2333.562)
|
||||
assert_state("sensor.power_apparent_fronius_meter_0_http_fronius", 5592.57)
|
||||
assert_state("sensor.power_factor_phase_1_fronius_meter_0_http_fronius", -0.99)
|
||||
assert_state("sensor.power_factor_phase_2_fronius_meter_0_http_fronius", -0.99)
|
||||
|
@ -215,12 +152,9 @@ async def test_symo_power_flow(hass, aioclient_mock):
|
|||
|
||||
# First test at night
|
||||
mock_responses(aioclient_mock, night=True)
|
||||
config = {
|
||||
CONF_SENSOR_TYPE: TYPE_POWER_FLOW,
|
||||
}
|
||||
await setup_fronius_integration(hass, [config])
|
||||
await setup_fronius_integration(hass)
|
||||
|
||||
assert len(hass.states.async_all()) == 12
|
||||
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 55
|
||||
# ignored: location, mode, timestamp
|
||||
#
|
||||
# states are rounded to 2 decimals
|
||||
|
@ -263,13 +197,15 @@ async def test_symo_power_flow(hass, aioclient_mock):
|
|||
|
||||
# Second test at daytime when inverter is producing
|
||||
mock_responses(aioclient_mock, night=False)
|
||||
async_fire_time_changed(hass, dt.utcnow() + DEFAULT_SCAN_INTERVAL)
|
||||
async_fire_time_changed(
|
||||
hass, dt.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 12
|
||||
# still 55 because power_flow update interval is shorter than others
|
||||
assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 55
|
||||
assert_state(
|
||||
"sensor.energy_day_fronius_power_flow_0_http_fronius",
|
||||
1101.70,
|
||||
1101.7001,
|
||||
)
|
||||
assert_state(
|
||||
"sensor.energy_total_fronius_power_flow_0_http_fronius",
|
||||
|
@ -297,7 +233,7 @@ async def test_symo_power_flow(hass, aioclient_mock):
|
|||
)
|
||||
assert_state(
|
||||
"sensor.relative_autonomy_fronius_power_flow_0_http_fronius",
|
||||
39.47,
|
||||
39.4708,
|
||||
)
|
||||
assert_state(
|
||||
"sensor.relative_self_consumption_fronius_power_flow_0_http_fronius",
|
||||
|
|
Loading…
Reference in New Issue