diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index 7ad1ec8e547..510d5409f5a 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -1,13 +1,24 @@ """Solar-Log integration.""" import logging +from urllib.parse import ParseResult, urlparse -from homeassistant.const import Platform +from aiohttp import CookieJar +from solarlog_cli.solarlog_connector import SolarLogConnector + +from homeassistant.const import CONF_HOST, CONF_TIMEOUT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_HAS_PWD -from .coordinator import SolarlogConfigEntry, SolarLogCoordinator +from .coordinator import ( + SolarLogBasicDataCoordinator, + SolarlogConfigEntry, + SolarLogDeviceDataCoordinator, + SolarLogLongtimeDataCoordinator, +) +from .models import SolarlogIntegrationData _LOGGER = logging.getLogger(__name__) @@ -16,10 +27,57 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: SolarlogConfigEntry) -> bool: """Set up a config entry for solarlog.""" - coordinator = SolarLogCoordinator(hass, entry) - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + + host_entry = entry.data[CONF_HOST] + password = entry.data.get("password", "") + + url = urlparse(host_entry, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + + solarlog = SolarLogConnector( + url.geturl(), + tz=hass.config.time_zone, + password=password, + session=async_create_clientsession( + hass, cookie_jar=CookieJar(quote_cookie=False) + ), + ) + + basic_coordinator = SolarLogBasicDataCoordinator(hass, entry, solarlog) + + solarLogData = SolarlogIntegrationData( + api=solarlog, + basic_data_coordinator=basic_coordinator, + ) + + await basic_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = solarLogData + + if basic_coordinator.solarlog.extended_data: + timeout = entry.data.get(CONF_TIMEOUT, 0) + if timeout <= 150: + # Increase timeout for next try, skip setup of LongtimeDataCoordinator, + # if timeout was not the issue (assumed when timeout > 150) + timeout = timeout + 30 + new = {**entry.data} + new[CONF_TIMEOUT] = timeout + hass.config_entries.async_update_entry(entry, data=new) + + entry.runtime_data.longtime_data_coordinator = ( + SolarLogLongtimeDataCoordinator(hass, entry, solarlog, timeout) + ) + await entry.runtime_data.longtime_data_coordinator.async_config_entry_first_refresh() + + entry.runtime_data.device_data_coordinator = SolarLogDeviceDataCoordinator( + hass, entry, solarlog + ) + await entry.runtime_data.device_data_coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 3d7d048a542..cc3028a3e7c 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -5,39 +5,41 @@ from __future__ import annotations from collections.abc import Callable from datetime import timedelta import logging -from urllib.parse import ParseResult, urlparse -from aiohttp import CookieJar from solarlog_cli.solarlog_connector import SolarLogConnector from solarlog_cli.solarlog_exceptions import ( SolarLogAuthenticationError, SolarLogConnectionError, SolarLogUpdateError, ) -from solarlog_cli.solarlog_models import SolarlogData +from solarlog_cli.solarlog_models import EnergyData, InverterData, SolarlogData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify from .const import DOMAIN +from .models import SolarlogIntegrationData _LOGGER = logging.getLogger(__name__) -type SolarlogConfigEntry = ConfigEntry[SolarLogCoordinator] +type SolarlogConfigEntry = ConfigEntry[SolarlogIntegrationData] -class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): - """Get and update the latest data.""" +class SolarLogBasicDataCoordinator(DataUpdateCoordinator[SolarlogData]): + """Get and update the basic solarlog data.""" config_entry: SolarlogConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: SolarlogConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarlogConfigEntry, + api: SolarLogConnector, + ) -> None: """Initialize the data object.""" super().__init__( hass, @@ -47,27 +49,8 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): update_interval=timedelta(seconds=60), ) - self.new_device_callbacks: list[Callable[[int], None]] = [] - self._devices_last_update: set[tuple[int, str]] = set() - - host_entry = config_entry.data[CONF_HOST] - password = config_entry.data.get("password", "") - - url = urlparse(host_entry, "http") - netloc = url.netloc or url.path - path = url.path if url.netloc else "" - url = ParseResult("http", netloc, path, *url[3:]) self.unique_id = config_entry.entry_id - self.host = url.geturl() - - self.solarlog = SolarLogConnector( - self.host, - tz=hass.config.time_zone, - password=password, - session=async_create_clientsession( - hass, cookie_jar=CookieJar(quote_cookie=False) - ), - ) + self.solarlog = api async def _async_setup(self) -> None: """Do initialization logic.""" @@ -82,13 +65,10 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): async def _async_update_data(self) -> SolarlogData: """Update the data from the SolarLog device.""" - _LOGGER.debug("Start data update") + _LOGGER.debug("Start basic data update") try: - data = await self.solarlog.update_data() - if self.solarlog.extended_data: - await self.solarlog.update_device_list() - data.inverter_data = await self.solarlog.update_inverter_data() + data = await self.solarlog.update_basic_data() except SolarLogConnectionError as ex: raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -112,57 +92,10 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): translation_key="update_failed", ) from ex - _LOGGER.debug("Data successfully updated") - - if self.solarlog.extended_data: - self._async_add_remove_devices(data) - _LOGGER.debug("Add_remove_devices finished") + _LOGGER.debug("Basic data successfully updated") return data - def _async_add_remove_devices(self, data: SolarlogData) -> None: - """Add new devices, remove non-existing devices.""" - if ( - current_devices := { - (k, self.solarlog.device_name(k)) for k in data.inverter_data - } - ) == self._devices_last_update: - return - - # remove old devices - if removed_devices := self._devices_last_update - current_devices: - _LOGGER.debug("Removed device(s): %s", ", ".join(map(str, removed_devices))) - device_registry = dr.async_get(self.hass) - - for removed_device in removed_devices: - device_name = "" - for did, dn in self._devices_last_update: - if did == removed_device[0]: - device_name = dn - break - if device := device_registry.async_get_device( - identifiers={ - ( - DOMAIN, - f"{self.unique_id}_{slugify(device_name)}", - ) - } - ): - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.unique_id, - ) - _LOGGER.debug("Device removed from device registry: %s", device.id) - - # add new devices - if new_devices := current_devices - self._devices_last_update: - _LOGGER.debug("New device(s) found: %s", ", ".join(map(str, new_devices))) - for device_id in new_devices: - for callback in self.new_device_callbacks: - callback(device_id[0]) - - self._devices_last_update = current_devices - async def renew_authentication(self) -> bool: """Renew access token for SolarLog API.""" logged_in = False @@ -182,3 +115,153 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): _LOGGER.debug("Credentials successfully updated? %s", logged_in) return logged_in + + +class SolarLogDeviceDataCoordinator(DataUpdateCoordinator[dict[int, InverterData]]): + """Get and update the device data of solarlog.""" + + config_entry: SolarlogConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarlogConfigEntry, + api: SolarLogConnector, + ) -> None: + """Initialize the data object.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="SolarLogDevices", + update_interval=timedelta(seconds=60), + ) + + self.new_device_callbacks: list[Callable[[int], None]] = [] + self._devices_last_update: set[tuple[int, str]] = set() + self.solarlog = api + + async def _async_update_data(self) -> dict[int, InverterData]: + """Update the data from the SolarLog device.""" + _LOGGER.debug("Start device data update") + + try: + await self.solarlog.update_device_list() + inverter_data = await self.solarlog.update_inverter_data() + except SolarLogAuthenticationError as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from ex + except (SolarLogConnectionError, SolarLogUpdateError) as ex: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from ex + + _LOGGER.debug("Device data successfully updated") + + self.data = inverter_data + + self._async_add_remove_devices(inverter_data) + + return inverter_data + + def _async_add_remove_devices(self, inverter_data: dict[int, InverterData]) -> None: + """Add new devices, remove non-existing devices.""" + + if ( + current_devices := { + (k, self.solarlog.device_name(k)) for k in inverter_data + } + ) == self._devices_last_update: + return + + # remove old devices + if removed_devices := self._devices_last_update - current_devices: + _LOGGER.info("Removed device(s): %s", ", ".join(map(str, removed_devices))) + device_registry = dr.async_get(self.hass) + + for removed_device in removed_devices: + device_name = "" + for did, dn in self._devices_last_update: + if did == removed_device[0]: + device_name = dn + break + if device := device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + f"{self.config_entry.entry_id}_{slugify(device_name)}", + ) + } + ): + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + _LOGGER.info("Device removed from device registry: %s", device.id) + + # add new devices + if new_devices := current_devices - self._devices_last_update: + _LOGGER.info("New device(s) found: %s", ", ".join(map(str, new_devices))) + for device_id in new_devices: + for callback in self.new_device_callbacks: + callback(device_id[0]) + + self._devices_last_update = current_devices + + +class SolarLogLongtimeDataCoordinator(DataUpdateCoordinator[EnergyData]): + """Get and update the solarlog longtime energy data.""" + + config_entry: SolarlogConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: SolarlogConfigEntry, + api: SolarLogConnector, + timeout: float, + ) -> None: + """Initialize the data object.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="SolarLogLongtimeEnergy", + update_interval=timedelta(seconds=timeout * 2), + ) + + self.solarlog = api + self.connection_timeout = timeout + + async def _async_update_data(self) -> EnergyData: + """Update the energy data from the SolarLog device.""" + _LOGGER.debug( + "Start energy data update with timeout=%s", self.connection_timeout + ) + + try: + energy_data: EnergyData | None = await self.solarlog.update_energy_data( + timeout=self.connection_timeout + ) + except SolarLogAuthenticationError as ex: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from ex + except (SolarLogConnectionError, SolarLogUpdateError) as ex: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from ex + + if energy_data is None: + energy_data = EnergyData(None, None) + + self.config_entry.runtime_data.basic_data_coordinator.data.self_consumption_year = energy_data.self_consumption + + _LOGGER.debug("Energy data successfully updated") + + return energy_data diff --git a/homeassistant/components/solarlog/diagnostics.py b/homeassistant/components/solarlog/diagnostics.py index c99222542ea..025f88b2ba6 100644 --- a/homeassistant/components/solarlog/diagnostics.py +++ b/homeassistant/components/solarlog/diagnostics.py @@ -19,7 +19,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: SolarlogConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data = config_entry.runtime_data.data + data = config_entry.runtime_data.basic_data_coordinator.data return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/solarlog/entity.py b/homeassistant/components/solarlog/entity.py index bfdc52dccf1..c6840dbc485 100644 --- a/homeassistant/components/solarlog/entity.py +++ b/homeassistant/components/solarlog/entity.py @@ -8,64 +8,84 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify from .const import DOMAIN -from .coordinator import SolarLogCoordinator +from .coordinator import ( + SolarLogBasicDataCoordinator, + SolarLogDeviceDataCoordinator, + SolarLogLongtimeDataCoordinator, +) -class SolarLogBaseEntity(CoordinatorEntity[SolarLogCoordinator]): - """SolarLog base entity.""" +class SolarLogBasicCoordinatorEntity(CoordinatorEntity[SolarLogBasicDataCoordinator]): + """Base SolarLog Coordinator entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: SolarLogCoordinator, + coordinator: SolarLogBasicDataCoordinator, description: SensorEntityDescription, ) -> None: - """Initialize the SolarLogCoordinator sensor.""" + """Initialize the SolarLogBasicCoordinator sensor.""" super().__init__(coordinator) - self.entity_description = description - - -class SolarLogCoordinatorEntity(SolarLogBaseEntity): - """Base SolarLog Coordinator entity.""" - - def __init__( - self, - coordinator: SolarLogCoordinator, - description: SensorEntityDescription, - ) -> None: - """Initialize the SolarLogCoordinator sensor.""" - super().__init__(coordinator, description) - - self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" self._attr_device_info = DeviceInfo( manufacturer="Solar-Log", model="Controller", - identifiers={(DOMAIN, coordinator.unique_id)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name="SolarLog", - configuration_url=coordinator.host, + configuration_url=coordinator.solarlog.host, ) + self.entity_description = description -class SolarLogInverterEntity(SolarLogBaseEntity): +class SolarLogInverterEntity(CoordinatorEntity[SolarLogDeviceDataCoordinator]): """Base SolarLog inverter entity.""" + _attr_has_entity_name = True + def __init__( self, - coordinator: SolarLogCoordinator, + coordinator: SolarLogDeviceDataCoordinator, description: SensorEntityDescription, device_id: int, ) -> None: """Initialize the SolarLogInverter sensor.""" - super().__init__(coordinator, description) - name = f"{coordinator.unique_id}_{slugify(coordinator.solarlog.device_name(device_id))}" + super().__init__(coordinator) + name = f"{coordinator.config_entry.entry_id}_{slugify(coordinator.solarlog.device_name(device_id))}" self._attr_unique_id = f"{name}_{description.key}" self._attr_device_info = DeviceInfo( manufacturer="Solar-Log", model="Inverter", identifiers={(DOMAIN, name)}, name=coordinator.solarlog.device_name(device_id), - via_device=(DOMAIN, coordinator.unique_id), + via_device=(DOMAIN, coordinator.config_entry.entry_id), ) self.device_id = device_id + self.entity_description = description + + +class SolarLogLongtimeCoordinatorEntity( + CoordinatorEntity[SolarLogLongtimeDataCoordinator] +): + """Base SolarLog Coordinator entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SolarLogLongtimeDataCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the SolarLogLongtimeCoordinator sensor.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + manufacturer="Solar-Log", + model="Controller", + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name="SolarLog", + configuration_url=coordinator.solarlog.host, + ) + self.entity_description = description diff --git a/homeassistant/components/solarlog/models.py b/homeassistant/components/solarlog/models.py new file mode 100644 index 00000000000..e259d899356 --- /dev/null +++ b/homeassistant/components/solarlog/models.py @@ -0,0 +1,25 @@ +"""The SolarLog integration models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from solarlog_cli.solarlog_connector import SolarLogConnector + + from .coordinator import ( + SolarLogBasicDataCoordinator, + SolarLogDeviceDataCoordinator, + SolarLogLongtimeDataCoordinator, + ) + + +@dataclass +class SolarlogIntegrationData: + """Data for the solarlog integration.""" + + api: SolarLogConnector + basic_data_coordinator: SolarLogBasicDataCoordinator + device_data_coordinator: SolarLogDeviceDataCoordinator | None = None + longtime_data_coordinator: SolarLogLongtimeDataCoordinator | None = None diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index a3a450fe49e..7931f1aba90 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -6,7 +6,12 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from solarlog_cli.solarlog_models import BatteryData, InverterData, SolarlogData +from solarlog_cli.solarlog_models import ( + BatteryData, + EnergyData, + InverterData, + SolarlogData, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,7 +30,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .coordinator import SolarlogConfigEntry -from .entity import SolarLogCoordinatorEntity, SolarLogInverterEntity +from .entity import ( + SolarLogBasicCoordinatorEntity, + SolarLogInverterEntity, + SolarLogLongtimeCoordinatorEntity, +) +from .models import SolarlogIntegrationData @dataclass(frozen=True, kw_only=True) @@ -35,6 +45,13 @@ class SolarLogCoordinatorSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[SolarlogData], StateType | datetime | None] +@dataclass(frozen=True, kw_only=True) +class SolarLogLongtimeSensorEntityDescription(SensorEntityDescription): + """Describes Solarlog longtime sensor entity.""" + + value_fn: Callable[[EnergyData], float | None] + + @dataclass(frozen=True, kw_only=True) class SolarLogBatterySensorEntityDescription(SensorEntityDescription): """Describes Solarlog battery sensor entity.""" @@ -49,7 +66,7 @@ class SolarLogInverterSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[InverterData], float | None] -SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = ( +SOLARLOG_BASIC_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = ( SolarLogCoordinatorSensorEntityDescription( key="last_updated", translation_key="last_update", @@ -193,14 +210,6 @@ SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = suggested_display_precision=3, value_fn=lambda data: data.consumption_total, ), - SolarLogCoordinatorSensorEntityDescription( - key="self_consumption_year", - translation_key="self_consumption_year", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda data: data.self_consumption_year, - ), SolarLogCoordinatorSensorEntityDescription( key="total_power", translation_key="total_power", @@ -254,7 +263,20 @@ SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = ), ) -BATTERY_SENSOR_TYPES: tuple[SolarLogBatterySensorEntityDescription, ...] = ( +"""SOLARLOG_LONGTIME_SENSOR_TYPES represent data points that may require longer timeout and +therefore are retrieved with different DataUpdateCoordinator.""" +SOLARLOG_LONGTIME_SENSOR_TYPES: tuple[SolarLogLongtimeSensorEntityDescription, ...] = ( + SolarLogLongtimeSensorEntityDescription( + key="self_consumption_year", + translation_key="self_consumption_year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.self_consumption, + ), +) + +SOLARLOG_BATTERY_SENSOR_TYPES: tuple[SolarLogBatterySensorEntityDescription, ...] = ( SolarLogBatterySensorEntityDescription( key="charging_power", translation_key="charging_power", @@ -281,7 +303,7 @@ BATTERY_SENSOR_TYPES: tuple[SolarLogBatterySensorEntityDescription, ...] = ( ), ) -INVERTER_SENSOR_TYPES: tuple[SolarLogInverterSensorEntityDescription, ...] = ( +SOLARLOG_INVERTER_SENSOR_TYPES: tuple[SolarLogInverterSensorEntityDescription, ...] = ( SolarLogInverterSensorEntityDescription( key="current_power", translation_key="current_power", @@ -313,41 +335,66 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add solarlog entry.""" - coordinator = entry.runtime_data + + solarLogIntegrationData: SolarlogIntegrationData = entry.runtime_data entities: list[SensorEntity] = [ - SolarLogCoordinatorSensor(coordinator, sensor) - for sensor in SOLARLOG_SENSOR_TYPES + SolarLogBasicCoordinatorSensor( + solarLogIntegrationData.basic_data_coordinator, sensor + ) + for sensor in SOLARLOG_BASIC_SENSOR_TYPES ] - # add battery sensors only if respective data is available (otherwise no battery attached to solarlog) - if coordinator.data.battery_data is not None: + if solarLogIntegrationData.longtime_data_coordinator is not None: entities.extend( - SolarLogBatterySensor(coordinator, sensor) - for sensor in BATTERY_SENSOR_TYPES + SolarLogLongtimeCoordinatorSensor( + solarLogIntegrationData.longtime_data_coordinator, sensor + ) + for sensor in SOLARLOG_LONGTIME_SENSOR_TYPES ) - device_data = coordinator.data.inverter_data + # add battery sensors only if respective data is available (otherwise no battery attached to solarlog) + if solarLogIntegrationData.basic_data_coordinator.data.battery_data is not None: + entities.extend( + SolarLogBatterySensor( + solarLogIntegrationData.basic_data_coordinator, sensor + ) + for sensor in SOLARLOG_BATTERY_SENSOR_TYPES + ) - if device_data: - entities.extend( - SolarLogInverterSensor(coordinator, sensor, device_id) - for device_id in device_data - for sensor in INVERTER_SENSOR_TYPES - ) + if solarLogIntegrationData.device_data_coordinator is not None: + device_data = solarLogIntegrationData.device_data_coordinator.data + + if device_data: + entities.extend( + SolarLogInverterSensor( + solarLogIntegrationData.device_data_coordinator, + sensor, + device_id, + ) + for device_id in device_data + for sensor in SOLARLOG_INVERTER_SENSOR_TYPES + ) + + def _async_add_new_device(device_id: int) -> None: + async_add_entities( + SolarLogInverterSensor( + solarLogIntegrationData.device_data_coordinator, + sensor, + device_id, + ) + for sensor in SOLARLOG_INVERTER_SENSOR_TYPES + if solarLogIntegrationData.device_data_coordinator is not None + ) + + solarLogIntegrationData.device_data_coordinator.new_device_callbacks.append( + _async_add_new_device + ) async_add_entities(entities) - def _async_add_new_device(device_id: int) -> None: - async_add_entities( - SolarLogInverterSensor(coordinator, sensor, device_id) - for sensor in INVERTER_SENSOR_TYPES - ) - coordinator.new_device_callbacks.append(_async_add_new_device) - - -class SolarLogCoordinatorSensor(SolarLogCoordinatorEntity, SensorEntity): +class SolarLogBasicCoordinatorSensor(SolarLogBasicCoordinatorEntity, SensorEntity): """Represents a SolarLog sensor.""" entity_description: SolarLogCoordinatorSensorEntityDescription @@ -359,7 +406,21 @@ class SolarLogCoordinatorSensor(SolarLogCoordinatorEntity, SensorEntity): return self.entity_description.value_fn(self.coordinator.data) -class SolarLogBatterySensor(SolarLogCoordinatorEntity, SensorEntity): +class SolarLogLongtimeCoordinatorSensor( + SolarLogLongtimeCoordinatorEntity, SensorEntity +): + """Represents a SolarLog longtime energy sensor.""" + + entity_description: SolarLogLongtimeSensorEntityDescription + + @property + def native_value(self) -> float | None: + """Return the state for this sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) + + +class SolarLogBatterySensor(SolarLogBasicCoordinatorEntity, SensorEntity): """Represents a SolarLog battery sensor.""" entity_description: SolarLogBatterySensorEntityDescription @@ -367,7 +428,10 @@ class SolarLogBatterySensor(SolarLogCoordinatorEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state for this sensor.""" - if (battery_data := self.coordinator.data.battery_data) is None: + if ( + battery_data + := self.coordinator.config_entry.runtime_data.basic_data_coordinator.data.battery_data + ) is None: return None return self.entity_description.value_fn(battery_data) @@ -381,6 +445,4 @@ class SolarLogInverterSensor(SolarLogInverterEntity, SensorEntity): def native_value(self) -> StateType: """Return the state for this sensor.""" - return self.entity_description.value_fn( - self.coordinator.data.inverter_data[self.device_id] - ) + return self.entity_description.value_fn(self.coordinator.data[self.device_id]) diff --git a/tests/components/solarlog/conftest.py b/tests/components/solarlog/conftest.py index 51d84c9b1a7..b9cf89884fc 100644 --- a/tests/components/solarlog/conftest.py +++ b/tests/components/solarlog/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest -from solarlog_cli.solarlog_models import InverterData, SolarlogData +from solarlog_cli.solarlog_models import EnergyData, InverterData, SolarlogData from homeassistant.components.solarlog.const import CONF_HAS_PWD, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD @@ -13,19 +13,6 @@ from .const import HOST from tests.common import MockConfigEntry, load_json_object_fixture -DEVICE_LIST = { - 0: InverterData(name="Inverter 1", enabled=True), - 1: InverterData(name="Inverter 2", enabled=True), -} -INVERTER_DATA = { - 0: InverterData( - name="Inverter 1", enabled=True, consumption_year=354687, current_power=5 - ), - 1: InverterData( - name="Inverter 2", enabled=True, consumption_year=354, current_power=6 - ), -} - @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -47,26 +34,35 @@ def mock_config_entry() -> MockConfigEntry: def mock_solarlog_connector(): """Build a fixture for the SolarLog API that connects successfully and returns one device.""" - data = SolarlogData.from_dict( - load_json_object_fixture("solarlog_data.json", DOMAIN) - ) - data.inverter_data = INVERTER_DATA - mock_solarlog_api = AsyncMock() + mock_solarlog_api.device_name = {0: "Inverter 1", 1: "Inverter 2"}.get + mock_solarlog_api.device_enabled = {0: True, 1: True}.get + mock_solarlog_api.extended_data.return_value = True + mock_solarlog_api.host = HOST + mock_solarlog_api.password.return_value = "pwd" mock_solarlog_api.set_enabled_devices = MagicMock() mock_solarlog_api.test_connection.return_value = True mock_solarlog_api.test_extended_data_available.return_value = True - mock_solarlog_api.extended_data.return_value = True - mock_solarlog_api.update_data.return_value = data - mock_solarlog_api.update_device_list.return_value = DEVICE_LIST - mock_solarlog_api.update_inverter_data.return_value = INVERTER_DATA - mock_solarlog_api.device_name = {0: "Inverter 1", 1: "Inverter 2"}.get - mock_solarlog_api.device_enabled = {0: True, 1: True}.get - mock_solarlog_api.password.return_value = "pwd" + mock_solarlog_api.update_basic_data.return_value = SolarlogData.from_dict( + load_json_object_fixture("solarlog_data.json", DOMAIN) + ) + mock_solarlog_api.update_device_list.return_value = { + 0: InverterData(name="Inverter 1", enabled=True), + 1: InverterData(name="Inverter 2", enabled=True), + } + mock_solarlog_api.update_energy_data.return_value = EnergyData(950, 545) + mock_solarlog_api.update_inverter_data.return_value = { + 0: InverterData( + name="Inverter 1", enabled=True, consumption_year=354687, current_power=5 + ), + 1: InverterData( + name="Inverter 2", enabled=True, consumption_year=354, current_power=6 + ), + } with ( patch( - "homeassistant.components.solarlog.coordinator.SolarLogConnector", + "homeassistant.components.solarlog.SolarLogConnector", autospec=True, return_value=mock_solarlog_api, ), diff --git a/tests/components/solarlog/fixtures/solarlog_data.json b/tests/components/solarlog/fixtures/solarlog_data.json index be29194a783..0a31836f022 100644 --- a/tests/components/solarlog/fixtures/solarlog_data.json +++ b/tests/components/solarlog/fixtures/solarlog_data.json @@ -15,7 +15,6 @@ "consumption_year": 4587, "consumption_total": 354687, "total_power": 120, - "self_consumption_year": 545, "alternator_loss": 2, "efficiency": 98.1, "usage": 54.8, diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index 212742b82f0..a632860dbd6 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -6,6 +6,7 @@ 'has_password': True, 'host': '**REDACTED**', 'password': 'pwd', + 'timeout': 30, }), 'disabled_by': None, 'discovery_keys': dict({ @@ -41,25 +42,13 @@ 'consumption_yesterday': 7.34, 'efficiency': 98.1, 'inverter_data': dict({ - '0': dict({ - 'consumption_year': 354687, - 'current_power': 5, - 'enabled': True, - 'name': 'Inverter 1', - }), - '1': dict({ - 'consumption_year': 354, - 'current_power': 6, - 'enabled': True, - 'name': 'Inverter 2', - }), }), 'last_updated': '2024-08-01T15:20:45+00:00', 'power_ac': 100.0, 'power_available': 45.13, 'power_dc': 102.0, 'production_year': None, - 'self_consumption_year': 545.0, + 'self_consumption_year': 545, 'total_power': 120.0, 'usage': 54.8, 'voltage_ac': 100.0, diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr index bbd9c761ae1..81207e0e309 100644 --- a/tests/components/solarlog/snapshots/test_sensor.ambr +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -1258,7 +1258,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '545.0', + 'state': '545', }) # --- # name: test_all_entities[sensor.solarlog_usage-entry] diff --git a/tests/components/solarlog/test_init.py b/tests/components/solarlog/test_init.py index 97a247015db..3590b4970a3 100644 --- a/tests/components/solarlog/test_init.py +++ b/tests/components/solarlog/test_init.py @@ -1,7 +1,9 @@ """Test the initialization.""" +from datetime import timedelta from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory import pytest from solarlog_cli.solarlog_exceptions import ( SolarLogAuthenticationError, @@ -9,10 +11,11 @@ from solarlog_cli.solarlog_exceptions import ( SolarLogError, SolarLogUpdateError, ) +from solarlog_cli.solarlog_models import EnergyData from homeassistant.components.solarlog.const import CONF_HAS_PWD, DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import CONF_HOST, CONF_TIMEOUT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceRegistry @@ -21,7 +24,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from . import setup_platform from .const import HOST -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_load_unload( @@ -86,7 +89,7 @@ async def test_auth_error_during_first_refresh( """Test the correct exceptions are thrown for auth error during first refresh.""" mock_solarlog_connector.password.return_value = "" - mock_solarlog_connector.update_data.side_effect = SolarLogAuthenticationError + mock_solarlog_connector.update_basic_data.side_effect = SolarLogAuthenticationError mock_solarlog_connector.login.return_value = login_return_value mock_solarlog_connector.login.side_effect = login_side_effect @@ -112,7 +115,7 @@ async def test_other_exceptions_during_first_refresh( ) -> None: """Test the correct exceptions are thrown during first refresh.""" - mock_solarlog_connector.update_data.side_effect = exception + mock_solarlog_connector.update_basic_data.side_effect = exception await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) await hass.async_block_till_done() @@ -180,3 +183,27 @@ async def test_migrate_config_entry( assert entry.minor_version == 3 assert entry.data[CONF_HOST] == HOST assert entry.data[CONF_HAS_PWD] is False + + +async def test_timeout_increase_refresh( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_solarlog_connector: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the correct timeout increase if API request for energy times out.""" + + mock_solarlog_connector.update_energy_data.side_effect = [ + SolarLogConnectionError, + EnergyData(950, 545), + ] + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await hass.async_block_till_done() + + freezer.tick(delta=timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry.data[CONF_TIMEOUT] == 60 diff --git a/tests/components/solarlog/test_sensor.py b/tests/components/solarlog/test_sensor.py index 132220c6261..3ae9a34b759 100644 --- a/tests/components/solarlog/test_sensor.py +++ b/tests/components/solarlog/test_sensor.py @@ -46,10 +46,10 @@ async def test_add_remove_entities( """Test if entities are added and old are removed.""" await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + # test no changes (coordinator.py line 176) assert hass.states.get("sensor.inverter_1_consumption_year").state == "354.687" - # test no changes (coordinator.py line 114) - freezer.tick(delta=timedelta(minutes=1)) + freezer.tick(delta=timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -68,7 +68,7 @@ async def test_add_remove_entities( mock_solarlog_connector.device_name = {0: "Inv 1", 2: "Inverter 3"}.get mock_solarlog_connector.device_enabled = {0: True, 2: True}.get - freezer.tick(delta=timedelta(minutes=1)) + freezer.tick(delta=timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -95,9 +95,16 @@ async def test_connection_error( """Test connection error.""" await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) - mock_solarlog_connector.update_data.side_effect = exception + mock_solarlog_connector.update_basic_data.side_effect = exception + mock_solarlog_connector.update_basic_data.side_effect = exception + mock_solarlog_connector.update_energy_data.side_effect = exception + mock_solarlog_connector.update_inverter_data.side_effect = exception - freezer.tick(delta=timedelta(hours=12)) + freezer.tick(delta=timedelta(hours=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + freezer.tick(delta=timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done()