diff --git a/.coveragerc b/.coveragerc index fbb761eef1f..f03ad280be6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/.strict-typing b/.strict-typing index 318a1367bef..e8941c307e6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index 7fe3842761d..242ffa1ea03 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index 2b4d968feca..9afd34ddc4a 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -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 diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py new file mode 100644 index 00000000000..fdcd5301830 --- /dev/null +++ b/homeassistant/components/fronius/config_flow.py @@ -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.""" diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py new file mode 100644 index 00000000000..de3e0cc9563 --- /dev/null +++ b/homeassistant/components/fronius/const.py @@ -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 diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py new file mode 100644 index 00000000000..7a8d156dd65 --- /dev/null +++ b/homeassistant/components/fronius/coordinator.py @@ -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] diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index e40b5303eca..217598aaed4 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -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" } diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 0ad172c2ab0..7c2a1bf6c09 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -1,348 +1,766 @@ """Support for Fronius devices.""" from __future__ import annotations -import copy -from datetime import timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from pyfronius import Fronius, FroniusError import voluptuous as vol from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, SensorEntity, + SensorEntityDescription, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - CONF_DEVICE, CONF_MONITORED_CONDITIONS, CONF_RESOURCE, - CONF_SCAN_INTERVAL, - CONF_SENSOR_TYPE, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + ENTITY_CATEGORY_DIAGNOSTIC, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_VOLT_AMPERE, + POWER_WATT, + TEMP_CELSIUS, ) -from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +if TYPE_CHECKING: + from . import FroniusSolarNet + from .coordinator import ( + FroniusCoordinatorBase, + FroniusInverterUpdateCoordinator, + FroniusLoggerUpdateCoordinator, + FroniusMeterUpdateCoordinator, + FroniusPowerFlowUpdateCoordinator, + FroniusStorageUpdateCoordinator, + ) _LOGGER = logging.getLogger(__name__) -CONF_SCOPE = "scope" +ELECTRIC_CHARGE_AMPERE_HOURS = "Ah" +ENERGY_VOLT_AMPERE_REACTIVE_HOUR = "varh" +POWER_VOLT_AMPERE_REACTIVE = "var" -TYPE_INVERTER = "inverter" -TYPE_STORAGE = "storage" -TYPE_METER = "meter" -TYPE_POWER_FLOW = "power_flow" -TYPE_LOGGER_INFO = "logger_info" -SCOPE_DEVICE = "device" -SCOPE_SYSTEM = "system" - -DEFAULT_SCOPE = SCOPE_DEVICE -DEFAULT_DEVICE = 0 -DEFAULT_INVERTER = 1 -DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) - -SENSOR_TYPES = [ - TYPE_INVERTER, - TYPE_STORAGE, - TYPE_METER, - TYPE_POWER_FLOW, - TYPE_LOGGER_INFO, -] -SCOPE_TYPES = [SCOPE_DEVICE, SCOPE_SYSTEM] - -PREFIX_DEVICE_CLASS_MAPPING = [ - ("state_of_charge", DEVICE_CLASS_BATTERY), - ("temperature", DEVICE_CLASS_TEMPERATURE), - ("power_factor", DEVICE_CLASS_POWER_FACTOR), - ("power", DEVICE_CLASS_POWER), - ("energy", DEVICE_CLASS_ENERGY), - ("current", DEVICE_CLASS_CURRENT), - ("timestamp", DEVICE_CLASS_TIMESTAMP), - ("voltage", DEVICE_CLASS_VOLTAGE), -] - -PREFIX_STATE_CLASS_MAPPING = [ - ("state_of_charge", STATE_CLASS_MEASUREMENT), - ("temperature", STATE_CLASS_MEASUREMENT), - ("power_factor", STATE_CLASS_MEASUREMENT), - ("power", STATE_CLASS_MEASUREMENT), - ("energy", STATE_CLASS_TOTAL_INCREASING), - ("current", STATE_CLASS_MEASUREMENT), - ("timestamp", STATE_CLASS_MEASUREMENT), - ("voltage", STATE_CLASS_MEASUREMENT), -] - - -def _device_id_validator(config): - """Ensure that inverters have default id 1 and other devices 0.""" - config = copy.deepcopy(config) - for cond in config[CONF_MONITORED_CONDITIONS]: - if CONF_DEVICE not in cond: - if cond[CONF_SENSOR_TYPE] == TYPE_INVERTER: - cond[CONF_DEVICE] = DEFAULT_INVERTER - else: - cond[CONF_DEVICE] = DEFAULT_DEVICE - return config - - -PLATFORM_SCHEMA = vol.Schema( - vol.All( - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_RESOURCE): cv.url, - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, - [ - { - vol.Required(CONF_SENSOR_TYPE): vol.In(SENSOR_TYPES), - vol.Optional(CONF_SCOPE, default=DEFAULT_SCOPE): vol.In( - SCOPE_TYPES - ), - vol.Optional(CONF_DEVICE): cv.positive_int, - } - ], - ), - } - ), - _device_id_validator, - ) +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_MONITORED_CONDITIONS): object, + } + ), ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up of Fronius platform.""" - session = async_get_clientsession(hass) - fronius = Fronius(session, config[CONF_RESOURCE]) - - scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - adapters = [] - # Creates all adapters for monitored conditions - for condition in config[CONF_MONITORED_CONDITIONS]: - - device = condition[CONF_DEVICE] - sensor_type = condition[CONF_SENSOR_TYPE] - scope = condition[CONF_SCOPE] - name = f"Fronius {condition[CONF_SENSOR_TYPE].replace('_', ' ').capitalize()} {device if scope == SCOPE_DEVICE else SCOPE_SYSTEM} {config[CONF_RESOURCE]}" - if sensor_type == TYPE_INVERTER: - if scope == SCOPE_SYSTEM: - adapter_cls = FroniusInverterSystem - else: - adapter_cls = FroniusInverterDevice - elif sensor_type == TYPE_METER: - if scope == SCOPE_SYSTEM: - adapter_cls = FroniusMeterSystem - else: - adapter_cls = FroniusMeterDevice - elif sensor_type == TYPE_POWER_FLOW: - adapter_cls = FroniusPowerFlow - elif sensor_type == TYPE_LOGGER_INFO: - adapter_cls = FroniusLoggerInfo - else: - adapter_cls = FroniusStorage - - adapters.append(adapter_cls(fronius, name, device, async_add_entities)) - - # Creates a lamdba that fetches an update when called - def adapter_data_fetcher(data_adapter): - async def fetch_data(*_): - await data_adapter.async_update() - - return fetch_data - - # Set up the fetching in a fixed interval for each adapter - for adapter in adapters: - fetch = adapter_data_fetcher(adapter) - # fetch data once at set-up - await fetch() - async_track_time_interval(hass, fetch, scan_interval) +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: None = None, +) -> None: + """Import Fronius configuration from yaml.""" + _LOGGER.warning( + "Loading Fronius via platform setup is deprecated. Please remove it from your yaml configuration" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) -class FroniusAdapter: - """The Fronius sensor fetching component.""" +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Fronius sensor entities based on a config entry.""" + solar_net: FroniusSolarNet = hass.data[DOMAIN][config_entry.entry_id] + for inverter_coordinator in solar_net.inverter_coordinators: + inverter_coordinator.add_entities_for_seen_keys( + async_add_entities, InverterSensor + ) + if solar_net.logger_coordinator is not None: + solar_net.logger_coordinator.add_entities_for_seen_keys( + async_add_entities, LoggerSensor + ) + if solar_net.meter_coordinator is not None: + solar_net.meter_coordinator.add_entities_for_seen_keys( + async_add_entities, MeterSensor + ) + if solar_net.power_flow_coordinator is not None: + solar_net.power_flow_coordinator.add_entities_for_seen_keys( + async_add_entities, PowerFlowSensor + ) + if solar_net.storage_coordinator is not None: + solar_net.storage_coordinator.add_entities_for_seen_keys( + async_add_entities, StorageSensor + ) + + +INVERTER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="energy_day", + name="Energy day", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_year", + name="Energy year", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_total", + name="Energy total", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="frequency_ac", + name="Frequency AC", + native_unit_of_measurement=FREQUENCY_HERTZ, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_ac", + name="AC Current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_dc", + name="DC current", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="current_dc_2", + name="DC Current 2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="power_ac", + name="AC power", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac", + name="AC voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_dc", + name="DC voltage", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="voltage_dc_2", + name="DC voltage 2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:current-dc", + ), + # device status entities + SensorEntityDescription( + key="inverter_state", + name="Inverter state", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + SensorEntityDescription( + key="error_code", + name="Error code", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + SensorEntityDescription( + key="status_code", + name="Status code", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + SensorEntityDescription( + key="led_state", + name="LED state", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + SensorEntityDescription( + key="led_color", + name="LED color", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), +] + +LOGGER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="co2_factor", + name="CO₂ factor", + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:molecule-co2", + ), + SensorEntityDescription( + key="cash_factor", + name="Grid export tariff", + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:cash-plus", + ), + SensorEntityDescription( + key="delivery_factor", + name="Grid import tariff", + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:cash-minus", + ), +] + +METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="current_ac_phase_1", + name="Current AC phase 1", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_ac_phase_2", + name="Current AC phase 2", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="current_ac_phase_3", + name="Current AC phase 3", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="energy_reactive_ac_consumed", + name="Energy reactive AC consumed", + native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + icon="mdi:lightning-bolt-outline", + ), + SensorEntityDescription( + key="energy_reactive_ac_produced", + name="Energy reactive AC produced", + native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, + state_class=STATE_CLASS_TOTAL_INCREASING, + icon="mdi:lightning-bolt-outline", + ), + SensorEntityDescription( + key="energy_real_ac_minus", + name="Energy real AC minus", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_real_ac_plus", + name="Energy real AC plus", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_real_consumed", + name="Energy real consumed", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_real_produced", + name="Energy real produced", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="frequency_phase_average", + name="Frequency phase average", + native_unit_of_measurement=FREQUENCY_HERTZ, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="meter_location", + name="Meter location", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_apparent_phase_1", + name="Power apparent phase 1", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash-outline", + ), + SensorEntityDescription( + key="power_apparent_phase_2", + name="Power apparent phase 2", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash-outline", + ), + SensorEntityDescription( + key="power_apparent_phase_3", + name="Power apparent phase 3", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash-outline", + ), + SensorEntityDescription( + key="power_apparent", + name="Power apparent", + native_unit_of_measurement=POWER_VOLT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash-outline", + ), + SensorEntityDescription( + key="power_factor_phase_1", + name="Power factor phase 1", + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_factor_phase_2", + name="Power factor phase 2", + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_factor_phase_3", + name="Power factor phase 3", + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_factor", + name="Power factor", + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_reactive_phase_1", + name="Power reactive phase 1", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash-outline", + ), + SensorEntityDescription( + key="power_reactive_phase_2", + name="Power reactive phase 2", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash-outline", + ), + SensorEntityDescription( + key="power_reactive_phase_3", + name="Power reactive phase 3", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash-outline", + ), + SensorEntityDescription( + key="power_reactive", + name="Power reactive", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:flash-outline", + ), + SensorEntityDescription( + key="power_real_phase_1", + name="Power real phase 1", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_real_phase_2", + name="Power real phase 2", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_real_phase_3", + name="Power real phase 3", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_real", + name="Power real", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac_phase_1", + name="Voltage AC phase 1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac_phase_2", + name="Voltage AC phase 2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac_phase_3", + name="Voltage AC phase 3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac_phase_to_phase_12", + name="Voltage AC phase 1-2", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac_phase_to_phase_23", + name="Voltage AC phase 2-3", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="voltage_ac_phase_to_phase_31", + name="Voltage AC phase 3-1", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), +] + +POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="energy_day", + name="Energy day", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_year", + name="Energy year", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="energy_total", + name="Energy total", + native_unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + SensorEntityDescription( + key="meter_mode", + name="Mode", + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_battery", + name="Power battery", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_grid", + name="Power grid", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_load", + name="Power load", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="power_photovoltaics", + name="Power photovoltaics", + native_unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="relative_autonomy", + name="Relative autonomy", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:home-circle-outline", + ), + SensorEntityDescription( + key="relative_self_consumption", + name="Relative self consumption", + native_unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:solar-power", + ), +] + +STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="capacity_maximum", + name="Capacity maximum", + native_unit_of_measurement=ELECTRIC_CHARGE_AMPERE_HOURS, + ), + SensorEntityDescription( + key="capacity_designed", + name="Capacity designed", + native_unit_of_measurement=ELECTRIC_CHARGE_AMPERE_HOURS, + ), + SensorEntityDescription( + key="current_dc", + name="Current DC", + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="voltage_dc", + name="Voltage DC", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="voltage_dc_maximum_cell", + name="Voltage DC maximum cell", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="voltage_dc_minimum_cell", + name="Voltage DC minimum cell", + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + icon="mdi:current-dc", + ), + SensorEntityDescription( + key="state_of_charge", + name="State of charge", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="temperature_cell", + name="Temperature cell", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), +] + + +class _FroniusSensorEntity(CoordinatorEntity, SensorEntity): + """Defines a Fronius coordinator entity.""" + + coordinator: FroniusCoordinatorBase + entity_descriptions: list[SensorEntityDescription] + _entity_id_prefix: str def __init__( - self, bridge: Fronius, name: str, device: int, add_entities: AddEntitiesCallback + self, + coordinator: FroniusCoordinatorBase, + key: str, + solar_net_id: str, ) -> None: - """Initialize the sensor.""" - self.bridge = bridge - self._name = name - self._device = device - self._fetched: dict[str, Any] = {} - self._available = True + """Set up an individual Fronius meter sensor.""" + super().__init__(coordinator) + self.entity_description = next( + desc for desc in self.entity_descriptions if desc.key == key + ) + # default entity_id added 2021.12 + # used for migration from non-unique_id entities of previous integration implementation + # when removed after migration period `_entity_id_prefix` will also no longer be needed + self.entity_id = f"{SENSOR_DOMAIN}.{key}_{DOMAIN}_{self._entity_id_prefix}_{coordinator.solar_net.host}" + self.solar_net_id = solar_net_id + self._attr_native_value = self._get_entity_value() - self.sensors: set[str] = set() - self._registered_sensors: set[SensorEntity] = set() - self._add_entities = add_entities + def _device_data(self) -> dict[str, Any]: + """Extract information for SolarNet device from coordinator data.""" + return self.coordinator.data[self.solar_net_id] - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def data(self): - """Return the state attributes.""" - return self._fetched - - @property - def available(self): - """Whether the fronius device is active.""" - return self._available - - async def async_update(self): - """Retrieve and update latest state.""" - try: - values = await self._update() - except FroniusError as err: - # fronius devices are often powered by self-produced solar energy - # and henced turned off at night. - # Therefore we will not print multiple errors when connection fails - if self._available: - self._available = False - _LOGGER.error("Failed to update: %s", err) - return - - self._available = True # reset connection failure - - attributes = self._fetched - # Copy data of current fronius device - for key, entry in values.items(): - # If the data is directly a sensor - if "value" in entry: - attributes[key] = entry - self._fetched = attributes - - # Add discovered value fields as sensors - # because some fields are only sent temporarily - new_sensors = [] - for key in attributes: - if key not in self.sensors: - self.sensors.add(key) - _LOGGER.info("Discovered %s, adding as sensor", key) - new_sensors.append(FroniusTemplateSensor(self, key)) - self._add_entities(new_sensors, True) - - # Schedule an update for all included sensors - for sensor in self._registered_sensors: - sensor.async_schedule_update_ha_state(True) - - async def _update(self) -> dict: - """Return values of interest.""" + def _get_entity_value(self) -> Any: + """Extract entity value from coordinator. Raises KeyError if not included in latest update.""" + new_value = self.coordinator.data[self.solar_net_id][ + self.entity_description.key + ]["value"] + return round(new_value, 4) if isinstance(new_value, float) else new_value @callback - def register(self, sensor): - """Register child sensor for update subscriptions.""" - self._registered_sensors.add(sensor) - return lambda: self._registered_sensors.remove(sensor) + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + try: + self._attr_native_value = self._get_entity_value() + except KeyError: + return + self.async_write_ha_state() -class FroniusInverterSystem(FroniusAdapter): - """Adapter for the fronius inverter with system scope.""" +class InverterSensor(_FroniusSensorEntity): + """Defines a Fronius inverter device sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_system_inverter_data() + entity_descriptions = INVERTER_ENTITY_DESCRIPTIONS + + def __init__( + self, + coordinator: FroniusInverterUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius inverter sensor.""" + self._entity_id_prefix = f"inverter_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + # device_info created in __init__ from a `GetInverterInfo` request + self._attr_device_info = coordinator.inverter_info.device_info + self._attr_unique_id = f"{coordinator.inverter_info.unique_id}-{key}" -class FroniusInverterDevice(FroniusAdapter): - """Adapter for the fronius inverter with device scope.""" +class LoggerSensor(_FroniusSensorEntity): + """Defines a Fronius logger device sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_inverter_data(self._device) + entity_descriptions = LOGGER_ENTITY_DESCRIPTIONS + _entity_id_prefix = "logger_info_0" + + def __init__( + self, + coordinator: FroniusLoggerUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius meter sensor.""" + super().__init__(coordinator, key, solar_net_id) + logger_data = self._device_data() + # Logger device is already created in FroniusSolarNet._create_solar_net_device + self._attr_device_info = coordinator.solar_net.system_device_info + self._attr_native_unit_of_measurement = logger_data[key].get("unit") + self._attr_unique_id = f'{logger_data["unique_identifier"]["value"]}-{key}' -class FroniusStorage(FroniusAdapter): - """Adapter for the fronius battery storage.""" +class MeterSensor(_FroniusSensorEntity): + """Defines a Fronius meter device sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_storage_data(self._device) + entity_descriptions = METER_ENTITY_DESCRIPTIONS + + def __init__( + self, + coordinator: FroniusMeterUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius meter sensor.""" + self._entity_id_prefix = f"meter_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + meter_data = self._device_data() + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, meter_data["serial"]["value"])}, + manufacturer=meter_data["manufacturer"]["value"], + model=meter_data["model"]["value"], + name=meter_data["model"]["value"], + via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), + ) + self._attr_unique_id = f'{meter_data["serial"]["value"]}-{key}' -class FroniusMeterSystem(FroniusAdapter): - """Adapter for the fronius meter with system scope.""" +class PowerFlowSensor(_FroniusSensorEntity): + """Defines a Fronius power flow sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_system_meter_data() + entity_descriptions = POWER_FLOW_ENTITY_DESCRIPTIONS + _entity_id_prefix = "power_flow_0" + + def __init__( + self, + coordinator: FroniusPowerFlowUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius power flow sensor.""" + super().__init__(coordinator, key, solar_net_id) + # SolarNet device is already created in FroniusSolarNet._create_solar_net_device + self._attr_device_info = coordinator.solar_net.system_device_info + self._attr_unique_id = ( + f"{coordinator.solar_net.solar_net_device_id}-power_flow-{key}" + ) -class FroniusMeterDevice(FroniusAdapter): - """Adapter for the fronius meter with device scope.""" +class StorageSensor(_FroniusSensorEntity): + """Defines a Fronius storage device sensor entity.""" - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_meter_data(self._device) + entity_descriptions = STORAGE_ENTITY_DESCRIPTIONS + def __init__( + self, + coordinator: FroniusStorageUpdateCoordinator, + key: str, + solar_net_id: str, + ) -> None: + """Set up an individual Fronius storage sensor.""" + self._entity_id_prefix = f"storage_{solar_net_id}" + super().__init__(coordinator, key, solar_net_id) + storage_data = self._device_data() -class FroniusPowerFlow(FroniusAdapter): - """Adapter for the fronius power flow.""" - - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_power_flow() - - -class FroniusLoggerInfo(FroniusAdapter): - """Adapter for the fronius power flow.""" - - async def _update(self): - """Get the values for the current state.""" - return await self.bridge.current_logger_info() - - -class FroniusTemplateSensor(SensorEntity): - """Sensor for the single values (e.g. pv power, ac power).""" - - def __init__(self, parent: FroniusAdapter, key: str) -> None: - """Initialize a singular value sensor.""" - self._key = key - self._attr_name = f"{key.replace('_', ' ').capitalize()} {parent.name}" - self._parent = parent - for prefix, device_class in PREFIX_DEVICE_CLASS_MAPPING: - if self._key.startswith(prefix): - self._attr_device_class = device_class - break - for prefix, state_class in PREFIX_STATE_CLASS_MAPPING: - if self._key.startswith(prefix): - self._attr_state_class = state_class - break - - @property - def should_poll(self): - """Device should not be polled, returns False.""" - return False - - @property - def available(self): - """Whether the fronius device is active.""" - return self._parent.available - - async def async_update(self): - """Update the internal state.""" - state = self._parent.data.get(self._key) - self._attr_native_value = state.get("value") - if isinstance(self._attr_native_value, float): - self._attr_native_value = round(self._attr_native_value, 2) - self._attr_native_unit_of_measurement = state.get("unit") - - async def async_added_to_hass(self): - """Register at parent component for updates.""" - self.async_on_remove(self._parent.register(self)) - - def __hash__(self): - """Hash sensor by hashing its name.""" - return hash(self.name) + self._attr_unique_id = f'{storage_data["serial"]["value"]}-{key}' + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, storage_data["serial"]["value"])}, + manufacturer=storage_data["manufacturer"]["value"], + model=storage_data["model"]["value"], + name=storage_data["model"]["value"], + via_device=(DOMAIN, coordinator.solar_net.solar_net_device_id), + ) diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json new file mode 100644 index 00000000000..7e411476559 --- /dev/null +++ b/homeassistant/components/fronius/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/fronius/translations/en.json b/homeassistant/components/fronius/translations/en.json new file mode 100644 index 00000000000..75bbeede6e0 --- /dev/null +++ b/homeassistant/components/fronius/translations/en.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 35bc6269d6c..d54e515681e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -102,6 +102,7 @@ FLOWS = [ "fritz", "fritzbox", "fritzbox_callmonitor", + "fronius", "garages_amsterdam", "gdacs", "geofency", diff --git a/mypy.ini b/mypy.ini index 6e9cc991f7f..da1fb4f08d4 100644 --- a/mypy.ini +++ b/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 diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 41413166157..e2ef369987c 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -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"), + ) diff --git a/tests/components/fronius/const.py b/tests/components/fronius/const.py deleted file mode 100644 index 7b7635e4d92..00000000000 --- a/tests/components/fronius/const.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Constants for Fronius tests.""" - -DOMAIN = "fronius" -MOCK_HOST = "http://fronius" diff --git a/tests/components/fronius/test_config_flow.py b/tests/components/fronius/test_config_flow.py new file mode 100644 index 00000000000..f0ff1e0ce48 --- /dev/null +++ b/tests/components/fronius/test_config_flow.py @@ -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, + } diff --git a/tests/components/fronius/test_coordinator.py b/tests/components/fronius/test_coordinator.py new file mode 100644 index 00000000000..b729c4d97ac --- /dev/null +++ b/tests/components/fronius/test_coordinator.py @@ -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 diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py new file mode 100644 index 00000000000..6ceeb273cba --- /dev/null +++ b/tests/components/fronius/test_init.py @@ -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) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 4564d26fa9f..fd64c127496 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -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",