diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index 7a6c07bc3ba6..44b18f0d7e1d 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -3,6 +3,7 @@ import datetime import json import logging +import math from typing import TYPE_CHECKING, Any, override import growattServer @@ -38,10 +39,46 @@ if TYPE_CHECKING: type GrowattConfigEntry = ConfigEntry[GrowattRuntimeData] SCAN_INTERVAL = datetime.timedelta(minutes=5) +_MAX_POWER_READING_AGE = datetime.timedelta(minutes=10) _LOGGER = logging.getLogger(__name__) +def _latest_power_value( + power_overview: dict[str, Any], now: datetime.datetime +) -> float | None: + """Return the newest recent power value from a plant power overview.""" + latest_time: datetime.datetime | None = None + latest_power: float | None = None + + for reading in power_overview.get("powers", []): + power = reading.get("power") + timestamp = reading.get("time") + if power is None or not isinstance(timestamp, str): + continue + try: + reading_time = dt_util.parse_datetime(timestamp) + except ValueError: + continue + if reading_time is None: + continue + if reading_time.tzinfo is None: + reading_time = reading_time.replace(tzinfo=dt_util.get_default_time_zone()) + if reading_time > now or now - reading_time > _MAX_POWER_READING_AGE: + continue + try: + power_value = float(power) + except TypeError, ValueError: + continue + if not math.isfinite(power_value): + continue + if latest_time is None or reading_time > latest_time: + latest_time = reading_time + latest_power = power_value + + return latest_power + + class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator to manage Growatt data fetching.""" @@ -205,8 +242,27 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) from err total_info["todayEnergy"] = total_info["today_energy"] total_info["totalEnergy"] = total_info["total_energy"] - # V1 API returns current_power in kW, convert to W - total_info["invTodayPpv"] = total_info["current_power"] * 1000 + # The plant power overview is authoritative for instantaneous power. + # Plant energy overview's current_power is only a fallback on error; + # it can report zero while production continues. + current_power = total_info["current_power"] * 1000 + now = dt_util.now() + try: + power_overview = self.api.plant_power_overview( + self.plant_id, now.date() + ) + except (growattServer.GrowattV1ApiError, RequestException) as err: + _LOGGER.debug( + "Failed to fetch plant power overview for %s: %s", + self.plant_id, + err, + ) + else: + if ( + latest_power := _latest_power_value(power_overview, now) + ) is not None: + current_power = latest_power + total_info["invTodayPpv"] = current_power else: # Classic API: use plant_info as before. # Copy the response to avoid mutating the dict returned by the library diff --git a/tests/components/growatt_server/conftest.py b/tests/components/growatt_server/conftest.py index 1bade8186406..cc68f609f9ef 100644 --- a/tests/components/growatt_server/conftest.py +++ b/tests/components/growatt_server/conftest.py @@ -29,6 +29,7 @@ def mock_growatt_v1_api(): Methods mocked for integration setup: - device_list: Called during async_setup_entry to discover devices - plant_energy_overview: Called by total coordinator during first refresh + - plant_power_overview: Called for the total coordinator's current power Methods mocked for MIN device coordinator refresh: - min_detail: Provides device state (e.g., acChargeEnable, chargePowerCommand) @@ -145,6 +146,10 @@ def mock_growatt_v1_api(): "total_energy": 1250.0, "current_power": 2.5, } + mock_v1_api.plant_power_overview.return_value = { + "count": 0, + "powers": [], + } # Called by switch/number entities during turn_on/turn_off/set_value mock_v1_api.min_write_parameter.return_value = None diff --git a/tests/components/growatt_server/test_sensor.py b/tests/components/growatt_server/test_sensor.py index 5f68610acc46..e84bd31346d8 100644 --- a/tests/components/growatt_server/test_sensor.py +++ b/tests/components/growatt_server/test_sensor.py @@ -1,7 +1,7 @@ """Tests for the Growatt Server sensor platform.""" -from datetime import timedelta -from unittest.mock import patch +from datetime import date, timedelta +from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory import growattServer @@ -183,6 +183,98 @@ async def test_sensor_coordinator_updates( assert state.state == "25.0" +@pytest.mark.parametrize( + ("powers", "expected_state"), + [ + pytest.param( + [ + {"time": "2026-07-14 18:50", "power": 1000.0}, + {"time": "2026-07-14 19:05", "power": 700.0}, + {"time": "invalid", "power": 900.0}, + {"time": "2026-07-14 18:55", "power": 880.6}, + {"time": "2026-07-14 18:57", "power": None}, + ], + "880.6", + id="newest-valid-reading", + ), + pytest.param( + [{"time": "2026-07-14 18:55", "power": 0.0}], + "0.0", + id="nighttime-zero", + ), + pytest.param( + [{"time": "2026-07-14 18:45", "power": 900.0}], + "2500.0", + id="stale-reading", + ), + pytest.param( + [ + {"time": None, "power": 900.0}, + {"time": "2026-02-30 12:00", "power": 900.0}, + ], + "2500.0", + id="invalid-timestamps", + ), + pytest.param( + [ + {"time": "2026-07-14 18:55", "power": "nan"}, + {"time": "2026-07-14 18:56", "power": "inf"}, + ], + "2500.0", + id="non-finite-power", + ), + pytest.param([], "2500.0", id="missing-readings"), + ], +) +@pytest.mark.freeze_time("2026-07-14 17:00:00+00:00") +async def test_v1_total_output_power_uses_recent_plant_power( + hass: HomeAssistant, + mock_growatt_v1_api: MagicMock, + mock_config_entry: MockConfigEntry, + powers: list[dict[str, str | float | None]], + expected_state: str, +) -> None: + """Test V1 total output power uses the newest recent plant power reading.""" + await hass.config.async_set_time_zone("Europe/Amsterdam") + mock_growatt_v1_api.plant_power_overview.return_value = { + "count": len(powers), + "powers": powers, + } + + with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.test_plant_total_output_power") + assert state is not None + assert state.state == expected_state + mock_growatt_v1_api.plant_power_overview.assert_called_once_with( + "123456", date(2026, 7, 14) + ) + + +@pytest.mark.freeze_time("2026-07-14 17:00:00+00:00") +async def test_v1_total_output_power_falls_back_on_power_api_error( + hass: HomeAssistant, + mock_growatt_v1_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test V1 total output power remains available when plant power fails.""" + mock_growatt_v1_api.plant_power_overview.side_effect = ( + growattServer.GrowattV1ApiError( + message="Rate limited", + error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED, + error_msg="Too many requests", + ) + ) + + with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.test_plant_total_output_power") + assert state is not None + assert state.state == "2500.0" + + async def test_sensor_unavailable_on_coordinator_error( hass: HomeAssistant, mock_growatt_v1_api,