diff --git a/.coveragerc b/.coveragerc index 957ec8620cb..52dfe089522 100644 --- a/.coveragerc +++ b/.coveragerc @@ -414,6 +414,7 @@ omit = homeassistant/components/goalfeed/* homeassistant/components/goodwe/__init__.py homeassistant/components/goodwe/button.py + homeassistant/components/goodwe/coordinator.py homeassistant/components/goodwe/number.py homeassistant/components/goodwe/select.py homeassistant/components/goodwe/sensor.py diff --git a/homeassistant/components/goodwe/__init__.py b/homeassistant/components/goodwe/__init__.py index a888752f508..b5872ed3dea 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -1,14 +1,12 @@ """The Goodwe inverter component.""" -import logging -from goodwe import InverterError, RequestFailedException, connect +from goodwe import InverterError, connect from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_MODEL_FAMILY, @@ -17,16 +15,13 @@ from .const import ( KEY_DEVICE_INFO, KEY_INVERTER, PLATFORMS, - SCAN_INTERVAL, ) - -_LOGGER = logging.getLogger(__name__) +from .coordinator import GoodweUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Goodwe components from a config entry.""" hass.data.setdefault(DOMAIN, {}) - name = entry.title host = entry.data[CONF_HOST] model_family = entry.data[CONF_MODEL_FAMILY] @@ -49,39 +44,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sw_version=f"{inverter.firmware} / {inverter.arm_firmware}", ) - async def async_update_data(): - """Fetch data from the inverter.""" - try: - return await inverter.read_runtime_data() - except RequestFailedException as ex: - # UDP communication with inverter is by definition unreliable. - # It is rather normal in many environments to fail to receive - # proper response in usual time, so we intentionally ignore isolated - # failures and report problem with availability only after - # consecutive streak of 3 of failed requests. - if ex.consecutive_failures_count < 3: - _LOGGER.debug( - "No response received (streak of %d)", ex.consecutive_failures_count - ) - # return empty dictionary, sensors will keep their previous values - return {} - # Inverter does not respond anymore (e.g. it went to sleep mode) - _LOGGER.debug( - "Inverter not responding (streak of %d)", ex.consecutive_failures_count - ) - raise UpdateFailed(ex) from ex - except InverterError as ex: - raise UpdateFailed(ex) from ex - # Create update coordinator - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=name, - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=SCAN_INTERVAL, - ) + coordinator = GoodweUpdateCoordinator(hass, entry, inverter) # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py new file mode 100644 index 00000000000..0ae064e0e97 --- /dev/null +++ b/homeassistant/components/goodwe/coordinator.py @@ -0,0 +1,80 @@ +"""Update coordinator for Goodwe.""" +from __future__ import annotations + +import logging +from typing import Any + +from goodwe import Inverter, InverterError, RequestFailedException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class GoodweUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Gather data for the energy device.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + inverter: Inverter, + ) -> None: + """Initialize update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=entry.title, + update_interval=SCAN_INTERVAL, + update_method=self._async_update_data, + ) + self.inverter: Inverter = inverter + self._last_data: dict[str, Any] = {} + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the inverter.""" + try: + self._last_data = self.data if self.data else {} + return await self.inverter.read_runtime_data() + except RequestFailedException as ex: + # UDP communication with inverter is by definition unreliable. + # It is rather normal in many environments to fail to receive + # proper response in usual time, so we intentionally ignore isolated + # failures and report problem with availability only after + # consecutive streak of 3 of failed requests. + if ex.consecutive_failures_count < 3: + _LOGGER.debug( + "No response received (streak of %d)", ex.consecutive_failures_count + ) + # return last known data + return self._last_data + # Inverter does not respond anymore (e.g. it went to sleep mode) + _LOGGER.debug( + "Inverter not responding (streak of %d)", ex.consecutive_failures_count + ) + raise UpdateFailed(ex) from ex + except InverterError as ex: + raise UpdateFailed(ex) from ex + + def sensor_value(self, sensor: str) -> Any: + """Answer current (or last known) value of the sensor.""" + val = self.data.get(sensor) + return val if val is not None else self._last_data.get(sensor) + + def total_sensor_value(self, sensor: str) -> Any: + """Answer current value of the 'total' (never 0) sensor.""" + val = self.data.get(sensor) + return val if val else self._last_data.get(sensor) + + def reset_sensor(self, sensor: str) -> None: + """Reset sensor value to 0. + + Intended for "daily" cumulative sensors (e.g. PV energy produced today), + which should be explicitly reset to 0 at midnight if inverter is suspended. + """ + self._last_data[sensor] = 0 + self.data[sensor] = 0 diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 2fbf56624cc..b4adf97c3e7 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta from decimal import Decimal import logging -from typing import Any, cast +from typing import Any from goodwe import Inverter, Sensor, SensorKind @@ -32,13 +32,11 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util from .const import DOMAIN, KEY_COORDINATOR, KEY_DEVICE_INFO, KEY_INVERTER +from .coordinator import GoodweUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -78,10 +76,12 @@ _ICONS: dict[SensorKind, str] = { class GoodweSensorEntityDescription(SensorEntityDescription): """Class describing Goodwe sensor entities.""" - value: Callable[[Any, Any], Any] = lambda prev, val: val + value: Callable[ + [GoodweUpdateCoordinator, str], Any + ] = lambda coordinator, sensor: coordinator.sensor_value(sensor) available: Callable[ - [CoordinatorEntity], bool - ] = lambda entity: entity.coordinator.last_update_success + [GoodweUpdateCoordinator], bool + ] = lambda coordinator: coordinator.last_update_success _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { @@ -108,8 +108,8 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value=lambda prev, val: val if val else prev, - available=lambda entity: entity.coordinator.data is not None, + value=lambda coordinator, sensor: coordinator.total_sensor_value(sensor), + available=lambda coordinator: coordinator.data is not None, ), "C": GoodweSensorEntityDescription( key="C", @@ -159,12 +159,14 @@ async def async_setup_entry( async_add_entities(entities) -class InverterSensor(CoordinatorEntity, SensorEntity): +class InverterSensor(CoordinatorEntity[GoodweUpdateCoordinator], SensorEntity): """Entity representing individual inverter sensor.""" + entity_description: GoodweSensorEntityDescription + def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: GoodweUpdateCoordinator, device_info: DeviceInfo, inverter: Inverter, sensor: Sensor, @@ -190,18 +192,12 @@ class InverterSensor(CoordinatorEntity, SensorEntity): if sensor.id_ == BATTERY_SOC: self._attr_device_class = SensorDeviceClass.BATTERY self._sensor = sensor - self._previous_value = None self._stop_reset: Callable[[], None] | None = None @property def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" - value = cast(GoodweSensorEntityDescription, self.entity_description).value( - self._previous_value, - self.coordinator.data.get(self._sensor.id_, self._previous_value), - ) - self._previous_value = value - return value + return self.entity_description.value(self.coordinator, self._sensor.id_) @property def available(self) -> bool: @@ -212,16 +208,18 @@ class InverterSensor(CoordinatorEntity, SensorEntity): as available even when the (non-battery) pv inverter is off-line during night and most of the sensors are actually unavailable. """ - return cast(GoodweSensorEntityDescription, self.entity_description).available( - self - ) + return self.entity_description.available(self.coordinator) @callback def async_reset(self, now): - """Reset the value back to 0 at midnight.""" + """Reset the value back to 0 at midnight. + + Some sensors values like daily produced energy are kept available, + even when the inverter is in sleep mode and no longer responds to request. + In contrast to "total" sensors, these "daily" sensors need to be reset to 0 on midnight. + """ if not self.coordinator.last_update_success: - self._previous_value = 0 - self.coordinator.data[self._sensor.id_] = 0 + self.coordinator.reset_sensor(self._sensor.id) self.async_write_ha_state() _LOGGER.debug("Goodwe reset %s to 0", self.name) next_midnight = dt_util.start_of_local_day(